diff --git a/LiquidProjections.sln b/LiquidProjections.sln index 4b6baab..e71f894 100644 --- a/LiquidProjections.sln +++ b/LiquidProjections.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26430.13 +VisualStudioVersion = 15.0.26430.12 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{27C8B175-6555-4591-87B1-177A2874FEA9}" ProjectSection(SolutionItems) = preProject @@ -18,11 +18,13 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiquidProjections.Specs", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExampleHost", "Samples\ExampleHost\ExampleHost.csproj", "{1B10C576-5212-402E-AC19-8CA1547E3F34}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiquidProjections", "Src\LiquidProjections\LiquidProjections.csproj", "{7B47454D-0129-43CB-AED5-27AFAFDB5D7C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LiquidProjections", "Src\LiquidProjections\LiquidProjections.csproj", "{7B47454D-0129-43CB-AED5-27AFAFDB5D7C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiquidProjections.Testing", "Src\LiquidProjections.Testing\LiquidProjections.Testing.csproj", "{B4EA7831-8282-4F3A-A057-2DEB59611A68}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LiquidProjections.Testing", "Src\LiquidProjections.Testing\LiquidProjections.Testing.csproj", "{B4EA7831-8282-4F3A-A057-2DEB59611A68}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiquidProjections.Abstractions", "Src\LiquidProjections.Abstractions\LiquidProjections.Abstractions.csproj", "{8E38C862-7DC9-4A7E-A2EE-921FFBC7EE2D}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LiquidProjections.Abstractions", "Src\LiquidProjections.Abstractions\LiquidProjections.Abstractions.csproj", "{8E38C862-7DC9-4A7E-A2EE-921FFBC7EE2D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiquidProjections.Owin", "Src\LiquidProjections.Owin\LiquidProjections.Owin.csproj", "{43C0A9ED-8094-4646-85A4-6EAA03505A20}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -50,6 +52,10 @@ Global {8E38C862-7DC9-4A7E-A2EE-921FFBC7EE2D}.Debug|Any CPU.Build.0 = Debug|Any CPU {8E38C862-7DC9-4A7E-A2EE-921FFBC7EE2D}.Release|Any CPU.ActiveCfg = Release|Any CPU {8E38C862-7DC9-4A7E-A2EE-921FFBC7EE2D}.Release|Any CPU.Build.0 = Release|Any CPU + {43C0A9ED-8094-4646-85A4-6EAA03505A20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {43C0A9ED-8094-4646-85A4-6EAA03505A20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43C0A9ED-8094-4646-85A4-6EAA03505A20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {43C0A9ED-8094-4646-85A4-6EAA03505A20}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -60,5 +66,6 @@ Global {7B47454D-0129-43CB-AED5-27AFAFDB5D7C} = {AE89AB5E-BC68-4AA3-9183-A222AC81691C} {B4EA7831-8282-4F3A-A057-2DEB59611A68} = {AE89AB5E-BC68-4AA3-9183-A222AC81691C} {8E38C862-7DC9-4A7E-A2EE-921FFBC7EE2D} = {AE89AB5E-BC68-4AA3-9183-A222AC81691C} + {43C0A9ED-8094-4646-85A4-6EAA03505A20} = {AE89AB5E-BC68-4AA3-9183-A222AC81691C} EndGlobalSection EndGlobal diff --git a/Samples/ExampleHost/App.config b/Samples/ExampleHost/App.config index 7c164fe..13c296d 100644 --- a/Samples/ExampleHost/App.config +++ b/Samples/ExampleHost/App.config @@ -1,7 +1,7 @@  - + diff --git a/Samples/ExampleHost/CountsProjector.cs b/Samples/ExampleHost/CountsProjector.cs index 7da5c41..b873c04 100644 --- a/Samples/ExampleHost/CountsProjector.cs +++ b/Samples/ExampleHost/CountsProjector.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using LiquidProjections.ExampleHost.Events; +using LiquidProjections.Statistics; namespace LiquidProjections.ExampleHost { @@ -9,13 +10,15 @@ public class CountsProjector { private readonly Dispatcher dispatcher; private readonly InMemoryDatabase store; + private readonly ProjectionStats stats; private ExampleProjector documentCountProjector; private ExampleProjector countryLookupProjector; - public CountsProjector(Dispatcher dispatcher, InMemoryDatabase store) + public CountsProjector(Dispatcher dispatcher, InMemoryDatabase store, ProjectionStats stats) { this.dispatcher = dispatcher; this.store = store; + this.stats = stats; BuildCountryProjector(); BuildDocumentProjector(); @@ -210,8 +213,11 @@ private void BuildDocumentProjector() period.Status = "Canceled"; }); - documentCountProjector = - new ExampleProjector(documentMapBuilder, store, countryLookupProjector); + documentCountProjector = + new ExampleProjector(documentMapBuilder, store, stats, countryLookupProjector) + { + Id = "DocumentCount" + }; } private string GetCountryName(Guid countryCode) @@ -229,7 +235,10 @@ private void BuildCountryProjector() .AsCreateOf(anEvent => anEvent.Code) .Using((country, anEvent) => country.Name = anEvent.Name); - countryLookupProjector = new ExampleProjector(countryMapBuilder, store); + countryLookupProjector = new ExampleProjector(countryMapBuilder, store, stats) + { + Id = "CountryLookup" + }; } private static IEnumerable GetPreviousContiguousValidPeriods(List allPeriods, diff --git a/Samples/ExampleHost/ExampleHost.csproj b/Samples/ExampleHost/ExampleHost.csproj index c03f633..bcbebf8 100644 --- a/Samples/ExampleHost/ExampleHost.csproj +++ b/Samples/ExampleHost/ExampleHost.csproj @@ -9,7 +9,7 @@ Properties LiquidProjections.ExampleHost LiquidProjections.ExampleHost - v4.5 + v4.5.2 512 @@ -135,11 +135,18 @@ {8e38c862-7dc9-4a7e-a2ee-921ffbc7ee2d} LiquidProjections.Abstractions + + {43c0a9ed-8094-4646-85a4-6eaa03505a20} + LiquidProjections.Owin + {7b47454d-0129-43cb-aed5-27afafdb5d7c} LiquidProjections + + + diff --git a/Samples/ExampleHost/ExampleProjector.cs b/Samples/ExampleHost/ExampleProjector.cs index c58eb40..7b4855f 100644 --- a/Samples/ExampleHost/ExampleProjector.cs +++ b/Samples/ExampleHost/ExampleProjector.cs @@ -1,7 +1,7 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using LiquidProjections.Statistics; namespace LiquidProjections.ExampleHost { @@ -13,14 +13,16 @@ public class ExampleProjector : IExampleProjector where TProjection : class, IEntity, new() { private readonly InMemoryDatabase store; - private readonly Projector innerProjector; + private readonly ProjectionStats stats; - public ExampleProjector(IEventMapBuilder mapBuilder, InMemoryDatabase store, params IExampleProjector[] childProjectors) + public ExampleProjector(IEventMapBuilder mapBuilder, InMemoryDatabase store, + ProjectionStats stats, params IExampleProjector[] childProjectors) { this.store = store; + this.stats = stats; var map = BuildMapFrom(mapBuilder); - innerProjector = new Projector(map, childProjectors.Select(p => p.InnerProjector)); + InnerProjector = new Projector(map, childProjectors.Select(p => p.InnerProjector)); } private IEventMap BuildMapFrom(IEventMapBuilder mapBuilder) @@ -53,16 +55,20 @@ private IEventMap BuildMapFrom(IEventMapBuilder transactions) + public async Task Handle(IReadOnlyList transactions) { - return innerProjector.Handle(transactions); + await InnerProjector.Handle(transactions); + + stats.TrackProgress(Id, transactions.Last().Checkpoint); } - public Projector InnerProjector => innerProjector; + public Projector InnerProjector { get; } + + public string Id { get; set; } } public interface IExampleProjector { Projector InnerProjector { get; } } -} +} \ No newline at end of file diff --git a/Samples/ExampleHost/Program.cs b/Samples/ExampleHost/Program.cs index 4d207f4..a0cc4f3 100644 --- a/Samples/ExampleHost/Program.cs +++ b/Samples/ExampleHost/Program.cs @@ -3,6 +3,8 @@ using System.IO; using System.Web.Http; using System.Web.Http.Dispatcher; +using LiquidProjections.Owin; +using LiquidProjections.Statistics; using Microsoft.Owin.Hosting; using Owin; using TinyIoC; @@ -22,10 +24,16 @@ public static void Main(string[] args) var dispatcher = new Dispatcher(eventStore.Subscribe); - var bootstrapper = new CountsProjector(dispatcher, projectionsStore); + var stats = new ProjectionStats(() => DateTime.UtcNow); + + var bootstrapper = new CountsProjector(dispatcher, projectionsStore, stats); var startOptions = new StartOptions($"http://localhost:9000"); - using (WebApp.Start(startOptions, builder => builder.UseControllers(container))) + using (WebApp.Start(startOptions, builder => + { + builder.UseControllers(container); + builder.UseLiquidProjections(stats); + })) { bootstrapper.Start(); diff --git a/Samples/ExampleHost/Properties/AssemblyInfo.cs b/Samples/ExampleHost/Properties/AssemblyInfo.cs index 9eb2d8a..648deb0 100644 --- a/Samples/ExampleHost/Properties/AssemblyInfo.cs +++ b/Samples/ExampleHost/Properties/AssemblyInfo.cs @@ -31,8 +31,8 @@ // // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: -// [assembly: AssemblyVersion("2.0.0.0")] -[assembly: AssemblyVersion("2.0.0.0")] -[assembly: AssemblyFileVersion("2.0.0.0")] +// [assembly: AssemblyVersion("2.1.1.0")] +[assembly: AssemblyVersion("2.1.1.0")] +[assembly: AssemblyFileVersion("2.1.1.0")] -[assembly: AssemblyInformationalVersion("2.0.0+Branch.master.Sha.6e68f840c3303bac97b10c567815a3bfef3184d7")] +[assembly: AssemblyInformationalVersion("2.1.1-Statistics.1+2.Branch.Statistics.Sha.d204fd580c5bb1f85bdcdd4819af6123c7315da8")] diff --git a/Src/LiquidProjections.Abstractions/AssemblyInfo.cs b/Src/LiquidProjections.Abstractions/AssemblyInfo.cs index 88280fa..ff6863c 100644 --- a/Src/LiquidProjections.Abstractions/AssemblyInfo.cs +++ b/Src/LiquidProjections.Abstractions/AssemblyInfo.cs @@ -7,6 +7,6 @@ [assembly: ComVisible(false)] -[assembly: AssemblyVersion("2.0.0.0")] -[assembly: AssemblyFileVersion("2.0.0.0")] -[assembly: AssemblyInformationalVersion("2.0.0+Branch.master.Sha.6e68f840c3303bac97b10c567815a3bfef3184d7")] \ No newline at end of file +[assembly: AssemblyVersion("2.1.1.0")] +[assembly: AssemblyFileVersion("2.1.1.0")] +[assembly: AssemblyInformationalVersion("2.1.1-Statistics.1+2.Branch.Statistics.Sha.d204fd580c5bb1f85bdcdd4819af6123c7315da8")] \ No newline at end of file diff --git a/Src/LiquidProjections.Owin/.nuspec b/Src/LiquidProjections.Owin/.nuspec new file mode 100644 index 0000000..74814a5 --- /dev/null +++ b/Src/LiquidProjections.Owin/.nuspec @@ -0,0 +1,26 @@ + + + + LiquidProjections.Owin + 0.0.0.0 + Dennis Doomen + Dennis Doomen + https://github.com/liquidprojections/LiquidProjections + false + Provides OWIN Middleware to access LiquidProjection over HTTP. + event-sourcing; projections; ddd; owin; webapi + + + + + + + + + + + + + + + diff --git a/Src/LiquidProjections.Owin/CustomNancyBootstrapper.cs b/Src/LiquidProjections.Owin/CustomNancyBootstrapper.cs new file mode 100644 index 0000000..b2e1d90 --- /dev/null +++ b/Src/LiquidProjections.Owin/CustomNancyBootstrapper.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using LiquidProjections.Statistics; +using Nancy; +using Nancy.Bootstrapper; +using Nancy.Configuration; +using Nancy.Extensions; +using Nancy.Linker; +using Nancy.Metadata.Modules; +using Nancy.Routing; +using Nancy.Swagger; +using Nancy.Swagger.Modules; +using Nancy.Swagger.Services; +using Nancy.TinyIoc; + +namespace LiquidProjections.Owin +{ + internal class CustomNancyBootstrapper : DefaultNancyBootstrapper + { + private readonly ProjectionStats stats; + + public CustomNancyBootstrapper(ProjectionStats stats) + { + this.stats = stats; + } + + protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines) + { + base.ApplicationStartup(container, pipelines); + + SwaggerMetadataProvider.SetInfo( + "LiquidProjections", + Assembly.GetExecutingAssembly().GetName().Version.ToString(), + "Provides statistics about running projectors" + ); + } + +#if DEBUG + + public override void Configure(INancyEnvironment environment) + { + environment.Tracing(enabled: false, displayErrorTraces: true); + base.Configure(environment); + } +#endif + + protected override IAssemblyCatalog AssemblyCatalog => new StaticAssemblyCatalog(); + + protected override ITypeCatalog TypeCatalog => new InternalTypeCatalog(AssemblyCatalog); + + protected override void ConfigureApplicationContainer(TinyIoCContainer container) + { + container.Register((x, overloads) => + new ResourceLinker(x.Resolve(), + x.Resolve(), x.Resolve())); + + container.Register("LinkerRegistrations"); + container.Register("SwaggerRegistrations"); + container.Register(stats); + } + + protected override IEnumerable Modules => + new[] + { + new ModuleRegistration(typeof(SwaggerModule)), + new ModuleRegistration(typeof(StatisticsModule)) + }; + } + + internal class StaticAssemblyCatalog : IAssemblyCatalog + { + public IReadOnlyCollection GetAssemblies() + { + return new[] + { + typeof(MetadataModuleRegistrations).Assembly, + typeof(CustomNancyBootstrapper).Assembly, + typeof(DefaultNancyBootstrapper).Assembly + }.Distinct().ToArray(); + } + } + + /// + /// Implementation of the interface that will find internal types as well. + /// + internal class InternalTypeCatalog : ITypeCatalog + { + private readonly IAssemblyCatalog assemblyCatalog; + private readonly ConcurrentDictionary> cache; + + /// + /// Initializes a new instance of the class. + /// + /// An instanced, used to get the assemblies that types should be resolved from. + public InternalTypeCatalog(IAssemblyCatalog assemblyCatalog) + { + this.assemblyCatalog = assemblyCatalog; + cache = new ConcurrentDictionary>(); + } + + /// + /// Gets all types that are assignable to the provided . + /// + /// The that returned types should be assignable to. + /// A that should be used when retrieving types. + /// An of instances. + public IReadOnlyCollection GetTypesAssignableTo(Type type, TypeResolveStrategy strategy) + { + return cache.GetOrAdd(type, t => GetTypesAssignableTo(type)) + .Where(strategy.Invoke).ToArray(); + } + + private IReadOnlyCollection GetTypesAssignableTo(Type type) + { + return assemblyCatalog.GetAssemblies() + .SelectMany(assembly => assembly.SafeGetTypes()) + .Where(type.IsAssignableFrom) + .Where(t => !t.GetTypeInfo().IsAbstract).ToArray(); + } + } +} \ No newline at end of file diff --git a/Src/LiquidProjections.Owin/LiquidProjections.Owin.csproj b/Src/LiquidProjections.Owin/LiquidProjections.Owin.csproj new file mode 100644 index 0000000..1e67875 --- /dev/null +++ b/Src/LiquidProjections.Owin/LiquidProjections.Owin.csproj @@ -0,0 +1,92 @@ + + + + + Debug + AnyCPU + {43C0A9ED-8094-4646-85A4-6EAA03505A20} + Library + Properties + LiquidProjections.Owin + LiquidProjections.Owin + v4.5.2 + 512 + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + bin\Debug\LiquidProjections.Owin.xml + 1701;1702;1705;1591 + true + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + bin\Release\LiquidProjections.Owin.xml + 1701;1702;1705;1591 + true + + + + ..\..\packages\Microsoft.Owin.3.1.0\lib\net45\Microsoft.Owin.dll + + + ..\..\packages\Nancy.2.0.0-clinteastwood\lib\net452\Nancy.dll + + + ..\..\packages\Nancy.Linker.0.3.1\lib\net40-Client\Nancy.Linker.dll + + + ..\..\packages\Nancy.Metadata.Modules.2.0.0-barneyrubble\lib\net452\Nancy.Metadata.Modules.dll + + + ..\..\packages\Nancy.Owin.2.0.0-clinteastwood\lib\net452\Nancy.Owin.dll + + + ..\..\packages\Nancy.Swagger.2.2.12-alpha\lib\net452\Nancy.Swagger.dll + + + ..\..\packages\Owin.1.0\lib\net40\Owin.dll + + + ..\..\packages\Swagger.ObjectModel.2.2.12-alpha\lib\net452\Swagger.ObjectModel.dll + + + + + + + + + + + + + + + + + + + + + + + + + + {7b47454d-0129-43cb-aed5-27afafdb5d7c} + LiquidProjections + + + + \ No newline at end of file diff --git a/Src/LiquidProjections.Owin/LiquidProjections.Owin.v3.ncrunchproject b/Src/LiquidProjections.Owin/LiquidProjections.Owin.v3.ncrunchproject new file mode 100644 index 0000000..0e3a2cf --- /dev/null +++ b/Src/LiquidProjections.Owin/LiquidProjections.Owin.v3.ncrunchproject @@ -0,0 +1,5 @@ + + + True + + \ No newline at end of file diff --git a/Src/LiquidProjections.Owin/MiddlewareExtensions.cs b/Src/LiquidProjections.Owin/MiddlewareExtensions.cs new file mode 100644 index 0000000..a5aad4f --- /dev/null +++ b/Src/LiquidProjections.Owin/MiddlewareExtensions.cs @@ -0,0 +1,19 @@ +using LiquidProjections.Statistics; +using Nancy.Owin; +using Owin; + +namespace LiquidProjections.Owin +{ + public static class MiddlewareExtensions + { + public static IAppBuilder UseLiquidProjections(this IAppBuilder appBuilder, ProjectionStats stats) + { + appBuilder.Map("/projectionStats", a => a.UseNancy(new NancyOptions + { + Bootstrapper = new CustomNancyBootstrapper(stats) + })); + + return appBuilder; + } + } +} \ No newline at end of file diff --git a/Src/LiquidProjections.Owin/Properties/AssemblyInfo.cs b/Src/LiquidProjections.Owin/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..e6003d6 --- /dev/null +++ b/Src/LiquidProjections.Owin/Properties/AssemblyInfo.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("LiquidProjections.Owin")] +[assembly: AssemblyDescription("OWIN middleware to access LiquidProjections over HTTP")] +[assembly: AssemblyProduct("LiquidProjections.Owin")] +[assembly: AssemblyCopyright("Copyright Dennis Doomen 2016-2017")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("43c0a9ed-8094-4646-85a4-6eaa03505a20")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("2.1.1.0")] +[assembly: AssemblyVersion("2.1.1.0")] +[assembly: AssemblyFileVersion("2.1.1.0")] + +[assembly: AssemblyInformationalVersion("2.1.1-Statistics.1+2.Branch.Statistics.Sha.d204fd580c5bb1f85bdcdd4819af6123c7315da8")] \ No newline at end of file diff --git a/Src/LiquidProjections.Owin/StatisticsModule.cs b/Src/LiquidProjections.Owin/StatisticsModule.cs new file mode 100644 index 0000000..d0e41e2 --- /dev/null +++ b/Src/LiquidProjections.Owin/StatisticsModule.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LiquidProjections.Statistics; +using Nancy; +using Nancy.Linker; +using Nancy.Swagger; +using Nancy.Swagger.Modules; +using Nancy.Swagger.Services; +using Nancy.Swagger.Services.RouteUtils; +using Swagger.ObjectModel; + +// ReSharper disable VirtualMemberCallInConstructor + +namespace LiquidProjections.Owin +{ + internal class StatisticsModule : NancyModule + { + public StatisticsModule(ProjectionStats stats, IResourceLinker resourceLinker) + { + Get("/", args => + { + var results = stats.GetForAllProjectors().OrderBy(p => p.ProjectorId).Select(p => new ProjectorSummary + { + ProjectorId = p.ProjectorId, + LastCheckpoint = stats[p.ProjectorId].LastCheckpoint.Checkpoint, + LastCheckpointUpdatedUtc = stats[p.ProjectorId].LastCheckpoint.TimestampUtc, + Url = Context.Request.Url + $"/{p.ProjectorId}" + }); + + return results; + }, null, "GetAll"); + + Get("/{id}", args => + { + string id = args.Id; + + return new + { + ProjectorId = id, + LastCheckpoint = stats[id].LastCheckpoint.Checkpoint, + LastCheckpointUpdatedUtc = stats[id].LastCheckpoint.TimestampUtc, + Properties = stats[id].GetProperties().Select(p => new ProjectorProperty + { + Key = p.Key, + Value = p.Value.Value, + LastUpdatedUtc = p.Value.TimestampUtc + }), + EventsUrl = resourceLinker.BuildAbsoluteUri(Context, "GetEvents", new + { + args.id + }).ToString() + }; + }, null, "GetSpecific"); + + Get("/{id}/events", args => + { + string id = args.Id; + + return new ProjectorEventCollection + { + ProjectorId = id, + Events = stats[id].GetEvents().Select(@event => new ProjectorEvent + { + Body = @event.Body, + TimestampUtc = @event.TimestampUtc + }) + }; + }, null, "GetEvents"); + + Get("/{id}/eta/{targetCheckpoint}", args => + { + string id = args.Id; + + TimeSpan? eta = stats[id].GetTimeToReach(args.targetCheckpoint); + + return new + { + Eta = eta + }; + }, null, "GetEta"); + } + } + + internal class StatisticsMetadataModule : SwaggerMetadataModule + { + public StatisticsMetadataModule(ISwaggerModelCatalog modelCatalog, ISwaggerTagCatalog tagCatalog) + : base(modelCatalog, tagCatalog) + { + SwaggerTypeMapping.AddTypeMapping(typeof(DateTime), typeof(DateTime)); + + RouteDescriber.AddBaseTag(new Tag + { + Description = "Operations for getting projection statistics", + Name = "Statistics" + }); + + RouteDescriber.DescribeRoute>("GetAll", "", + "Returns a list of all known projectors and a summary of their status", new[] + { + new HttpResponseMetadata {Code = 200, Message = "OK"} + }); + + RouteDescriber + .DescribeRoute("GetSpecific", "", "Returns the details of a specific projector", new[] + { + new HttpResponseMetadata {Code = 200, Message = "OK"} + }) + .Parameter(p => p.Name("id").In(ParameterIn.Path).Description("Identifies the projector")); + + + RouteDescriber + .DescribeRoute("GetEvents", "", "Returns the events logged for a specific projector", new[] + { + new HttpResponseMetadata {Code = 200, Message = "OK"} + }) + .Parameter(p => p.Name("id").In(ParameterIn.Path).Description("Identifies the projector")); ; + + RouteDescriber + .DescribeRoute("GetEta", "", "Returns the ETA for a specific projector to reach a certain checkpoint", new[] + { + new HttpResponseMetadata {Code = 200, Message = "OK"} + }) + .Parameter(p => p.Name("id").In(ParameterIn.Path).Description("Identifies the projector")) + .Parameter(p => p.Name("targetCheckpoint").In(ParameterIn.Path).Description("The target checkpoint for which to calculate the ETA")); + + RouteDescriber.AddAdditionalModels( + typeof(ProjectorEvent), typeof(ProjectorProperty), typeof(ProjectorSummary)); + } + } + + internal class ProjectorEventCollection + { + public string ProjectorId { get; set; } + public IEnumerable Events { get; set; } + } + + internal class ProjectorProperty + { + public string Key { get; set; } + public string Value { get; set; } + public DateTime LastUpdatedUtc { get; set; } + } + + internal class ProjectorSummary + { + public string ProjectorId { get; set; } + public long LastCheckpoint { get; set; } + public DateTime LastCheckpointUpdatedUtc { get; set; } + public string Url { get; set; } + } + + internal class ProjectorEvent + { + public string Body { get; set; } + public DateTime TimestampUtc { get; set; } + } + + internal class ProjectorDetails + { + public string ProjectorId { get; set; } + public long LastCheckpoint { get; set; } + public DateTime LastCheckpointUpdatedUtc { get; set; } + public ProjectorProperty[] Properties { get; set; } + public string EventsUrl { get; set; } + } +} \ No newline at end of file diff --git a/Src/LiquidProjections.Owin/packages.config b/Src/LiquidProjections.Owin/packages.config new file mode 100644 index 0000000..037a9ee --- /dev/null +++ b/Src/LiquidProjections.Owin/packages.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/Src/LiquidProjections.Testing/AssemblyInfo.cs b/Src/LiquidProjections.Testing/AssemblyInfo.cs index 9f29d43..10e4f57 100644 --- a/Src/LiquidProjections.Testing/AssemblyInfo.cs +++ b/Src/LiquidProjections.Testing/AssemblyInfo.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; using System.Runtime.InteropServices; [assembly: AssemblyTitle("LiquidProjections.Testing")] @@ -7,6 +7,6 @@ [assembly: ComVisible(false)] -[assembly: AssemblyVersion("2.0.0.0")] -[assembly: AssemblyFileVersion("2.0.0.0")] -[assembly: AssemblyInformationalVersion("2.0.0+Branch.master.Sha.6e68f840c3303bac97b10c567815a3bfef3184d7")] \ No newline at end of file +[assembly: AssemblyVersion("2.1.1.0")] +[assembly: AssemblyFileVersion("2.1.1.0")] +[assembly: AssemblyInformationalVersion("2.1.1-Statistics.1+2.Branch.Statistics.Sha.d204fd580c5bb1f85bdcdd4819af6123c7315da8")] \ No newline at end of file diff --git a/Src/LiquidProjections/AssemblyInfo.cs b/Src/LiquidProjections/AssemblyInfo.cs index 4eea4dc..8e09ed4 100644 --- a/Src/LiquidProjections/AssemblyInfo.cs +++ b/Src/LiquidProjections/AssemblyInfo.cs @@ -7,6 +7,6 @@ [assembly: ComVisible(false)] -[assembly: AssemblyVersion("2.0.0.0")] -[assembly: AssemblyFileVersion("2.0.0.0")] -[assembly: AssemblyInformationalVersion("2.0.0+Branch.master.Sha.6e68f840c3303bac97b10c567815a3bfef3184d7")] \ No newline at end of file +[assembly: AssemblyVersion("2.1.1.0")] +[assembly: AssemblyFileVersion("2.1.1.0")] +[assembly: AssemblyInformationalVersion("2.1.1-Statistics.1+2.Branch.Statistics.Sha.d204fd580c5bb1f85bdcdd4819af6123c7315da8")] \ No newline at end of file diff --git a/Src/LiquidProjections/LiquidProjections.csproj b/Src/LiquidProjections/LiquidProjections.csproj index c0df36b..e198fa2 100644 --- a/Src/LiquidProjections/LiquidProjections.csproj +++ b/Src/LiquidProjections/LiquidProjections.csproj @@ -23,6 +23,7 @@ + diff --git a/Src/LiquidProjections/Statistics/Event.cs b/Src/LiquidProjections/Statistics/Event.cs new file mode 100644 index 0000000..6b98238 --- /dev/null +++ b/Src/LiquidProjections/Statistics/Event.cs @@ -0,0 +1,17 @@ +using System; + +namespace LiquidProjections.Statistics +{ + public class Event + { + public Event(string body, DateTime timestampUtc) + { + Body = body; + TimestampUtc = timestampUtc; + } + + public string Body { get; } + + public DateTime TimestampUtc { get; } + } +} \ No newline at end of file diff --git a/Src/LiquidProjections/Statistics/ProjectionStats.cs b/Src/LiquidProjections/Statistics/ProjectionStats.cs new file mode 100644 index 0000000..94a00e7 --- /dev/null +++ b/Src/LiquidProjections/Statistics/ProjectionStats.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace LiquidProjections.Statistics +{ + /// + /// Provides a thread-safe place to store all kinds of run-time information about the progress of a projector. + /// + public class ProjectionStats + { + private readonly Func nowUtc; + private readonly ConcurrentDictionary stats = new ConcurrentDictionary(); + + public ProjectionStats(Func nowUtc) + { + this.nowUtc = nowUtc; + } + + /// + /// Should be called to track the progress of a projector and use that to calculate an ETA. + /// + public void TrackProgress(string projectorId, long checkpoint) + { + this[projectorId].TrackProgress(checkpoint, nowUtc()); + } + + /// + /// Can be used to store projector-specific properties that characterize the projector's configuration or state. + /// + /// + /// Each property is identified by a . This class only keeps the latest value + /// for each property. + /// + public void StoreProperty(string projectorId, string name, string value) + { + this[projectorId].StoreProperty(name, value, nowUtc()); + } + + /// + /// Can be used to store information that happened that can help diagnose the state or failure of a projector. + /// + public void LogEvent(string projectorId, string body) + { + this[projectorId].LogEvent(body, nowUtc()); + } + + /// + /// Calculates the expected time for the projector identified by to reach a + /// certain based on a weighted average over the last + /// ten minutes, or null if there is not enough information yet. Use to report + /// progress. + /// + public TimeSpan? GetTimeToReach(string projectorId, long targetCheckpoint) + { + return this[projectorId].GetTimeToReach(targetCheckpoint); + } + + /// + /// Gets the statistics for a particular projector. + /// + public ProjectorStats this[string projectorId] + { + get + { + return stats.GetOrAdd(projectorId, id => new ProjectorStats(id, nowUtc)); + } + } + + public IEnumerable GetForAllProjectors() + { + return stats.ToArray().Select(projectorStatsById => projectorStatsById.Value); + } + } +} diff --git a/Src/LiquidProjections/Statistics/ProjectorStats.cs b/Src/LiquidProjections/Statistics/ProjectorStats.cs new file mode 100644 index 0000000..0f15d14 --- /dev/null +++ b/Src/LiquidProjections/Statistics/ProjectorStats.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace LiquidProjections.Statistics +{ + /// + /// Contains statistics and information about a particular projector. + /// + /// + /// An instance of this class is safe for use in multi-threaded solutions. + /// + public class ProjectorStats + { + private readonly object eventsSyncObject = new object(); + private readonly object progressSyncObject = new object(); + private readonly ConcurrentDictionary properties; + private readonly List events; + + private readonly WeightedProjectionSpeedCalculator lastMinuteSamples = + new WeightedProjectionSpeedCalculator(12, TimeSpan.FromSeconds(5)); + + private readonly WeightedProjectionSpeedCalculator last10MinuteSamples + = new WeightedProjectionSpeedCalculator(9, TimeSpan.FromMinutes(1)); + + private TimestampedCheckpoint lastCheckpoint; + + public ProjectorStats(string projectorId, Func nowUtc) + { + properties = new ConcurrentDictionary(); + events = new List(); + ProjectorId = projectorId; + lastCheckpoint = new TimestampedCheckpoint(0, nowUtc()); + } + + public ProjectorStats(string projectorId, IDictionary properties, IEnumerable events) + { + this.properties = new ConcurrentDictionary(properties); + this.events = this.events.ToList(); + ProjectorId = projectorId; + } + + public string ProjectorId { get; } + + public TimestampedCheckpoint LastCheckpoint + { + get + { + lock (progressSyncObject) + { + return lastCheckpoint; + } + } + } + + /// + /// Gets a snapshot of the properties stored for this projector at the time of calling. + /// + public IDictionary GetProperties() => properties.ToArray().ToDictionary(p => p.Key, p => p.Value); + + /// + /// Gets a snapshot of the events stored for this projector at the time of calling. + /// + public IReadOnlyList GetEvents() + { + lock (eventsSyncObject) + { + return events.ToReadOnly(); + } + } + + public void StoreProperty(string key, string value, DateTime timestampUtc) + { + properties[key] = new Property(value, timestampUtc); + } + + public void LogEvent(string body, DateTime timestampUtc) + { + lock (eventsSyncObject) + { + events.Add(new Event(body, timestampUtc)); + } + } + + /// + /// Calculates the expected time for a projector to reach a certain based + /// on a weighted average over the last ten minutes, or null if there is not enough information yet. + /// + public TimeSpan? GetTimeToReach(long targetCheckpoint) + { + lock (progressSyncObject) + { + if (lastCheckpoint == null) + { + return null; + } + + if (targetCheckpoint <= lastCheckpoint.Checkpoint) + { + return TimeSpan.Zero; + } + + float speed = lastMinuteSamples.GetWeightedSpeed(); + + speed = (speed == 0) + ? last10MinuteSamples.GetWeightedSpeed() + : last10MinuteSamples.GetWeightedSpeedIncluding(speed); + + if (speed == 0) + { + return null; + } + + float secondsWithFractionalPart = (targetCheckpoint - lastCheckpoint.Checkpoint) / speed; + + if (secondsWithFractionalPart > long.MaxValue) + { + return null; + } + + long secondsWithoutFractionalPart = (long) secondsWithFractionalPart; + + return TimeSpan.FromSeconds(secondsWithoutFractionalPart); + } + } + + public void TrackProgress(long checkpoint, DateTime timestampUtc) + { + lock (progressSyncObject) + { + lastMinuteSamples.Record(checkpoint, timestampUtc); + last10MinuteSamples.Record(checkpoint, timestampUtc); + lastCheckpoint = new TimestampedCheckpoint(checkpoint, timestampUtc); + } + } + } +} \ No newline at end of file diff --git a/Src/LiquidProjections/Statistics/Property.cs b/Src/LiquidProjections/Statistics/Property.cs new file mode 100644 index 0000000..2c696b8 --- /dev/null +++ b/Src/LiquidProjections/Statistics/Property.cs @@ -0,0 +1,17 @@ +using System; + +namespace LiquidProjections.Statistics +{ + public class Property + { + public string Value { get; } + + public DateTime TimestampUtc { get; } + + public Property(string value, DateTime timestampUtc) + { + Value = value; + TimestampUtc = timestampUtc; + } + } +} \ No newline at end of file diff --git a/Src/LiquidProjections/Statistics/TimestampedCheckpoint.cs b/Src/LiquidProjections/Statistics/TimestampedCheckpoint.cs new file mode 100644 index 0000000..db0db79 --- /dev/null +++ b/Src/LiquidProjections/Statistics/TimestampedCheckpoint.cs @@ -0,0 +1,17 @@ +using System; + +namespace LiquidProjections.Statistics +{ + public class TimestampedCheckpoint + { + public TimestampedCheckpoint(long checkpoint, DateTime timestampUtc) + { + Checkpoint = checkpoint; + TimestampUtc = timestampUtc; + } + + public long Checkpoint { get; } + + public DateTime TimestampUtc { get; } + } +} \ No newline at end of file diff --git a/Src/LiquidProjections/Statistics/WeightedProjectionSpeedCalculator.cs b/Src/LiquidProjections/Statistics/WeightedProjectionSpeedCalculator.cs new file mode 100644 index 0000000..ea395aa --- /dev/null +++ b/Src/LiquidProjections/Statistics/WeightedProjectionSpeedCalculator.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace LiquidProjections.Statistics +{ + /// + /// Calculates the weighted speed in transactions per second. + /// + /// + /// This class is not thread-safe. + /// A or another synchronization method should be used to ensure thread-safe usage. + /// + public class WeightedProjectionSpeedCalculator + { + private readonly TimeSpan threshold; + private readonly int maxNrOfSamples; + private readonly Queue samples = new Queue(); + private DateTime? lastSampleTimeStampUtc; + private long? lastCheckpoint; + + public WeightedProjectionSpeedCalculator(int maxNrOfSamples, TimeSpan threshold) + { + this.maxNrOfSamples = maxNrOfSamples; + this.threshold = threshold; + } + + private bool HasBaselineBeenSet => lastSampleTimeStampUtc != null; + + public void Record(long checkpoint, DateTime timestampUtc) + { + if (HasBaselineBeenSet) + { + TimeSpan interval = timestampUtc - lastSampleTimeStampUtc.Value; + + if (interval > threshold) + { + long delta = checkpoint - lastCheckpoint.Value; + + samples.Enqueue((float) (delta / interval.TotalSeconds)); + + lastCheckpoint = checkpoint; + lastSampleTimeStampUtc = timestampUtc; + + DiscardOlderSamples(); + } + } + else + { + SetBaseline(checkpoint, timestampUtc); + } + } + + private void SetBaseline(long checkpoint, DateTime timestampUtc) + { + lastCheckpoint = checkpoint; + lastSampleTimeStampUtc = timestampUtc; + } + + private void DiscardOlderSamples() + { + while (samples.Count > maxNrOfSamples) + { + samples.Dequeue(); + } + } + + public float GetWeightedSpeedIncluding(float sample) + { + return GetWeightedSpeed(samples.Concat(new[] { sample })); + } + + public float GetWeightedSpeed() + { + return GetWeightedSpeed(samples); + } + + public float GetWeightedSpeed(IEnumerable effectiveSamples) + { + float weightedSum = 0; + int weights = 0; + int weight = 0; + + foreach (float sample in effectiveSamples) + { + weight++; + weights += weight; + weightedSum += sample * weight; + } + + return weights == 0 ? 0 : weightedSum / weights; + } + } +} \ No newline at end of file diff --git a/Tests/LiquidProjections.Specs/DispatcherSpecs.cs b/Tests/LiquidProjections.Specs/DispatcherSpecs.cs index f8c8094..90994d2 100644 --- a/Tests/LiquidProjections.Specs/DispatcherSpecs.cs +++ b/Tests/LiquidProjections.Specs/DispatcherSpecs.cs @@ -43,7 +43,10 @@ public When_a_projector_throws_an_exception() When(() => { - return The().Write(new List()); + return The().Write(new List + { + new Transaction() + }); }); } diff --git a/Tests/LiquidProjections.Specs/LiquidProjections.Specs.csproj b/Tests/LiquidProjections.Specs/LiquidProjections.Specs.csproj index f9b3bcc..09e40ed 100644 --- a/Tests/LiquidProjections.Specs/LiquidProjections.Specs.csproj +++ b/Tests/LiquidProjections.Specs/LiquidProjections.Specs.csproj @@ -43,6 +43,21 @@ ..\..\packages\FluentAssertions.4.19.2\lib\net45\FluentAssertions.Core.dll + + ..\..\packages\FluentAssertions.Json.4.19.0\lib\net45\FluentAssertions.Json.dll + + + ..\..\packages\Microsoft.Owin.3.1.0\lib\net45\Microsoft.Owin.dll + + + ..\..\packages\Newtonsoft.Json.10.0.3\lib\net45\Newtonsoft.Json.dll + + + ..\..\packages\Owin.1.0\lib\net40\Owin.dll + + + ..\..\packages\OwinHttpMessageHandler.1.3.8\lib\net45\OwinHttpMessageHandler.dll + @@ -68,8 +83,10 @@ + + @@ -78,6 +95,10 @@ {8e38c862-7dc9-4a7e-a2ee-921ffbc7ee2d} LiquidProjections.Abstractions + + {43c0a9ed-8094-4646-85a4-6eaa03505a20} + LiquidProjections.Owin + {b4ea7831-8282-4f3a-a057-2deb59611a68} LiquidProjections.Testing @@ -91,6 +112,7 @@ + diff --git a/Tests/LiquidProjections.Specs/ProjectionStatsSpecs.cs b/Tests/LiquidProjections.Specs/ProjectionStatsSpecs.cs new file mode 100644 index 0000000..98c3189 --- /dev/null +++ b/Tests/LiquidProjections.Specs/ProjectionStatsSpecs.cs @@ -0,0 +1,389 @@ +using System; +using FluentAssertions; +using LiquidProjections.Statistics; +using Xunit; + +namespace LiquidProjections.Specs +{ + public class ProjectionStatsSpecs + { + [Fact] + public void When_checking_in_multiple_times_for_a_projector_it_should_remember_the_last_only() + { + //----------------------------------------------------------------------------------------------------------- + // Arrange + //----------------------------------------------------------------------------------------------------------- + DateTime nowUtc = 16.June(2017).At(15, 00).AsUtc(); + + var stats = new ProjectionStats(() => nowUtc); + + //----------------------------------------------------------------------------------------------------------- + // Act + //----------------------------------------------------------------------------------------------------------- + stats.TrackProgress("myProjector", 1000); + + nowUtc = nowUtc.Add(1.Hours()); + + stats.TrackProgress("myProjector", 2000); + + //----------------------------------------------------------------------------------------------------------- + // Assert + //----------------------------------------------------------------------------------------------------------- + var projectorStats = stats.GetForAllProjectors().Should().ContainSingle(s => s.ProjectorId == "myProjector").Subject; + projectorStats.LastCheckpoint.Checkpoint.Should().Be(2000); + projectorStats.LastCheckpoint.TimestampUtc.Should().Be(nowUtc); + } + + [Fact] + public void When_multiple_properties_are_registered_under_the_same_name_it_should_only_remember_the_last_one() + { + DateTime nowUtc = 16.June(2017).At(15, 00).AsUtc(); + + var stats = new ProjectionStats(() => nowUtc); + + //----------------------------------------------------------------------------------------------------------- + // Act + //----------------------------------------------------------------------------------------------------------- + stats.StoreProperty("myProjector", "theName", "aValue"); + + nowUtc = nowUtc.Add(1.Hours()); + + stats.StoreProperty("myProjector", "theName", "anotherValue"); + + //----------------------------------------------------------------------------------------------------------- + // Assert + //----------------------------------------------------------------------------------------------------------- + var projectorStats = stats.GetForAllProjectors().Should().ContainSingle(s => s.ProjectorId == "myProjector").Subject; + + projectorStats.GetProperties().Should().ContainKey("theName"); + projectorStats.GetProperties()["theName"].ShouldBeEquivalentTo(new + { + Value = "anotherValue", + TimestampUtc = nowUtc + }); + } + + [Fact] + public void When_multiple_properties_are_registered_under_different_names_it_should_remember_all() + { + DateTime nowUtc = 16.June(2017).At(15, 00).AsUtc(); + + var stats = new ProjectionStats(() => nowUtc); + + //----------------------------------------------------------------------------------------------------------- + // Act + //----------------------------------------------------------------------------------------------------------- + stats.StoreProperty("myProjector", "aName", "aValue"); + + var firstUtc = nowUtc; + nowUtc = nowUtc.Add(1.Hours()); + + stats.StoreProperty("myProjector", "anotherName", "anotherValue"); + + //----------------------------------------------------------------------------------------------------------- + // Assert + //----------------------------------------------------------------------------------------------------------- + var projectorStats = stats.GetForAllProjectors().Should().ContainSingle(s => s.ProjectorId == "myProjector").Subject; + + projectorStats.GetProperties().Should().ContainKey("aName"); + projectorStats.GetProperties()["aName"].ShouldBeEquivalentTo(new + { + Value = "aValue", + TimestampUtc = firstUtc + }); + + projectorStats.GetProperties().Should().ContainKey("anotherName"); + projectorStats.GetProperties()["anotherName"].ShouldBeEquivalentTo(new + { + Value = "anotherValue", + TimestampUtc = nowUtc + }); + } + + [Fact] + public void When_multiple_events_are_registered_it_should_remember_their_timestamps() + { + //----------------------------------------------------------------------------------------------------------- + // Arrange + //----------------------------------------------------------------------------------------------------------- + DateTime nowUtc = 16.June(2017).At(15, 00).AsUtc(); + + var stats = new ProjectionStats(() => nowUtc); + + //----------------------------------------------------------------------------------------------------------- + // Act + //----------------------------------------------------------------------------------------------------------- + stats.LogEvent("myProjector", "first event"); + + nowUtc = nowUtc.At(16, 00).AsUtc(); + stats.LogEvent("myProjector", "second event"); + + //----------------------------------------------------------------------------------------------------------- + // Assert + //----------------------------------------------------------------------------------------------------------- + var projectorStats = stats.GetForAllProjectors().Should().ContainSingle(s => s.ProjectorId == "myProjector").Subject; + projectorStats.GetEvents().ShouldAllBeEquivalentTo(new[] + { + new + { + Body = "first event", + TimestampUtc = nowUtc.At(15, 00) + }, + new + { + Body = "second event", + TimestampUtc = nowUtc.At(16, 00) + } + }); + } + + [Fact] + public void When_the_projector_runs_at_a_constant_speed_it_should_use_that_to_calculate_the_eta() + { + //----------------------------------------------------------------------------------------------------------- + // Arrange + //----------------------------------------------------------------------------------------------------------- + DateTime nowUtc = 16.June(2017).At(15, 00).AsUtc(); + + var stats = new ProjectionStats(() => nowUtc); + + long transactionsPerSecond = 1000; + + //----------------------------------------------------------------------------------------------------------- + // Act + //----------------------------------------------------------------------------------------------------------- + long checkpoint = 0; + + for (int seconds = 0; seconds < 60; ++seconds) + { + checkpoint += transactionsPerSecond; + + stats.TrackProgress("myProjector", checkpoint); + + nowUtc = nowUtc.Add(1.Seconds()); + } + + //----------------------------------------------------------------------------------------------------------- + // Assert + //----------------------------------------------------------------------------------------------------------- + TimeSpan? eta = stats.GetTimeToReach("myProjector", 100000); + + long secondsToComplete = (100000 - checkpoint) / transactionsPerSecond; + + eta.Should().Be(TimeSpan.FromSeconds(secondsToComplete)); + } + + [Fact] + public void When_the_projector_runs_at_a_very_low_speed_it_should_still_calculate_the_eta() + { + //----------------------------------------------------------------------------------------------------------- + // Arrange + //----------------------------------------------------------------------------------------------------------- + DateTime nowUtc = 16.June(2017).At(15, 00).AsUtc(); + + var stats = new ProjectionStats(() => nowUtc); + + long transactionsPer5Seconds = 1; + + //----------------------------------------------------------------------------------------------------------- + // Act + //----------------------------------------------------------------------------------------------------------- + long checkpoint = 0; + + for (int seconds = 0; seconds < 60; seconds += 5) + { + checkpoint += transactionsPer5Seconds; + + stats.TrackProgress("myProjector", checkpoint); + + nowUtc = nowUtc.Add(5.Seconds()); + } + + //----------------------------------------------------------------------------------------------------------- + // Assert + //----------------------------------------------------------------------------------------------------------- + TimeSpan? eta = stats.GetTimeToReach("myProjector", 100000); + + long secondsToComplete =((100000 - checkpoint) / transactionsPer5Seconds * 5); + + eta.Should().BeCloseTo(TimeSpan.FromSeconds(secondsToComplete), 1000); + } + + [Fact] + public void When_the_projectors_speed_increases_it_should_favor_the_higher_speed_in_the_eta() + { + //----------------------------------------------------------------------------------------------------------- + // Arrange + //----------------------------------------------------------------------------------------------------------- + DateTime nowUtc = 16.June(2017).At(15, 00).AsUtc(); + + var stats = new ProjectionStats(() => nowUtc); + + long transactionsPerSecond = 1000; + + //----------------------------------------------------------------------------------------------------------- + // Act + //----------------------------------------------------------------------------------------------------------- + long checkpoint = 0; + + for (int seconds = 0; seconds < 60; ++seconds) + { + checkpoint += transactionsPerSecond; + + stats.TrackProgress("myProjector", checkpoint); + + nowUtc = nowUtc.Add(1.Seconds()); + transactionsPerSecond += 100; + } + + //----------------------------------------------------------------------------------------------------------- + // Assert + //----------------------------------------------------------------------------------------------------------- + TimeSpan? eta = stats.GetTimeToReach("myProjector", checkpoint + 100000); + + long weightedAveragePerSecond = 4550; + + long secondsToComplete = 100000 / weightedAveragePerSecond; + + eta.Should().Be(TimeSpan.FromSeconds(secondsToComplete)); + } + + [Fact] + public void When_the_projectors_speed_decreases_it_should_favor_the_lower_speed_in_the_eta() + { + //----------------------------------------------------------------------------------------------------------- + // Arrange + //----------------------------------------------------------------------------------------------------------- + DateTime nowUtc = 16.June(2017).At(15, 00).AsUtc(); + + var stats = new ProjectionStats(() => nowUtc); + + long transactionsPerSecond = 1000; + + //----------------------------------------------------------------------------------------------------------- + // Act + //----------------------------------------------------------------------------------------------------------- + long checkpoint = 0; + + for (int seconds = 0; seconds < 60; ++seconds) + { + checkpoint += transactionsPerSecond; + + stats.TrackProgress("myProjector", checkpoint); + + nowUtc = nowUtc.Add(1.Seconds()); + transactionsPerSecond -= 10; + } + + //----------------------------------------------------------------------------------------------------------- + // Assert + //----------------------------------------------------------------------------------------------------------- + TimeSpan? eta = stats.GetTimeToReach("myProjector", checkpoint + 100000); + + long weightedAveragePerSecond = 645; + + long secondsToComplete = 100000 / weightedAveragePerSecond; + + eta.Should().Be(TimeSpan.FromSeconds(secondsToComplete)); + } + + [Fact] + public void When_the_projector_runs_for_more_than_10_minutes_it_should_only_evaluate_the_last_10_minutes() + { + //----------------------------------------------------------------------------------------------------------- + // Arrange + //----------------------------------------------------------------------------------------------------------- + DateTime nowUtc = 16.June(2017).At(15, 00).AsUtc(); + + var stats = new ProjectionStats(() => nowUtc); + + //----------------------------------------------------------------------------------------------------------- + // Act + //----------------------------------------------------------------------------------------------------------- + long checkpoint = 0; + + // The first ten minutes should be ignored + for (int seconds = 0; seconds < (10 * 60); ++seconds) + { + checkpoint += 1000; + + stats.TrackProgress("myProjector", checkpoint); + + nowUtc = nowUtc.Add(1.Seconds()); + } + + // Then nine minutes of 2000/s. + for (int seconds = 0; seconds < (9 * 60); ++seconds) + { + checkpoint += 2000; + + stats.TrackProgress("myProjector", checkpoint); + + nowUtc = nowUtc.Add(1.Seconds()); + } + + // The last minute should run on 3000/s + for (int seconds = 0; seconds < (60); ++seconds) + { + checkpoint += 3000; + + stats.TrackProgress("myProjector", checkpoint); + + nowUtc = nowUtc.Add(1.Seconds()); + } + + //----------------------------------------------------------------------------------------------------------- + // Assert + //----------------------------------------------------------------------------------------------------------- + TimeSpan? eta = stats.GetTimeToReach("myProjector", checkpoint + 100000); + + float precalculatedWeightedAveragePerSecond = 2222.5022F; + + long secondsToComplete = (long) (100000 / precalculatedWeightedAveragePerSecond); + + eta.Should().Be(TimeSpan.FromSeconds(secondsToComplete)); + } + + [Fact] + public void When_the_projector_is_ahead_of_the_requested_checkpoint_the_eta_should_be_zero() + { + //----------------------------------------------------------------------------------------------------------- + // Arrange + //----------------------------------------------------------------------------------------------------------- + DateTime utcNow = new DateTime(2017, 7, 4, 11, 50, 0, DateTimeKind.Utc); + var stats = new ProjectionStats(() => utcNow); + stats.TrackProgress("myProjector", 1000); + + //----------------------------------------------------------------------------------------------------------- + // Act + //----------------------------------------------------------------------------------------------------------- + utcNow = new DateTime(2017, 7, 4, 11, 52, 0, DateTimeKind.Utc); + stats.TrackProgress("myProjector", 10000); + + //----------------------------------------------------------------------------------------------------------- + // Assert + //----------------------------------------------------------------------------------------------------------- + TimeSpan? eta = stats.GetTimeToReach("myProjector", 5000); + eta.Should().Be(TimeSpan.Zero); + } + + [Fact] + public void When_the_projector_has_not_checked_in_yet_it_should_not_provide_an_eta() + { + //----------------------------------------------------------------------------------------------------------- + // Arrange + //----------------------------------------------------------------------------------------------------------- + var stats = new ProjectionStats(() => DateTime.UtcNow); + + //----------------------------------------------------------------------------------------------------------- + // Act + //----------------------------------------------------------------------------------------------------------- + TimeSpan? eta = stats.GetTimeToReach("myProjector", 100000); + + //----------------------------------------------------------------------------------------------------------- + // Assert + //----------------------------------------------------------------------------------------------------------- + eta.Should().NotHaveValue(); + } + } +} diff --git a/Tests/LiquidProjections.Specs/Properties/AssemblyInfo.cs b/Tests/LiquidProjections.Specs/Properties/AssemblyInfo.cs index a02fa55..6a88976 100644 --- a/Tests/LiquidProjections.Specs/Properties/AssemblyInfo.cs +++ b/Tests/LiquidProjections.Specs/Properties/AssemblyInfo.cs @@ -31,8 +31,8 @@ // // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: -// [assembly: AssemblyVersion("2.0.0.0")] -[assembly: AssemblyVersion("2.0.0.0")] -[assembly: AssemblyFileVersion("2.0.0.0")] +// [assembly: AssemblyVersion("2.1.1.0")] +[assembly: AssemblyVersion("2.1.1.0")] +[assembly: AssemblyFileVersion("2.1.1.0")] -[assembly: AssemblyInformationalVersion("2.0.0+Branch.master.Sha.6e68f840c3303bac97b10c567815a3bfef3184d7")] +[assembly: AssemblyInformationalVersion("2.1.1-Statistics.1+2.Branch.Statistics.Sha.d204fd580c5bb1f85bdcdd4819af6123c7315da8")] diff --git a/Tests/LiquidProjections.Specs/StatisticsHttpApiSpecs.cs b/Tests/LiquidProjections.Specs/StatisticsHttpApiSpecs.cs new file mode 100644 index 0000000..3564927 --- /dev/null +++ b/Tests/LiquidProjections.Specs/StatisticsHttpApiSpecs.cs @@ -0,0 +1,245 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Chill; +using FluentAssertions; +using FluentAssertions.Json; +using LiquidProjections.Owin; +using LiquidProjections.Statistics; +using Microsoft.Owin.Builder; +using Newtonsoft.Json.Linq; +using Xunit; + +// ReSharper disable ConvertToLambdaExpression + +namespace LiquidProjections.Specs +{ + namespace StatisticsHttpApiSpecs + { + public class When_no_specific_projector_is_requested : GivenSubject + { + public When_no_specific_projector_is_requested() + { + Given(() => + { + var nowUtc = 10.July(2017).At(10, 39).AsUtc(); + var stats = new ProjectionStats(() => nowUtc); + stats.TrackProgress("id1", 1000); + stats.TrackProgress("id2", 1000); + + var appBuilder = new AppBuilder(); + appBuilder.UseLiquidProjections(stats); + + var httpClient = new HttpClient(new OwinHttpMessageHandler(appBuilder.Build())); + httpClient.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + + WithSubject(ct => httpClient); + + }); + + When(async () => + { + return await Subject.GetAsync("http://localhost/projectionStats/"); + }); + } + + [Fact] + public async Task Then_it_should_provide_a_list_of_all_projectors() + { + var jtoken = JToken.Parse(await Result.Content.ReadAsStringAsync()); + + jtoken.Should().Be(JToken.Parse(@" + [ + { + ""projectorId"": ""id1"", + ""lastCheckpoint"": 1000, + ""lastCheckpointUpdatedUtc"": ""2017-07-10T10:39:00Z"", + ""url"": ""http://localhost/projectionStats/id1"" + }, + { + ""projectorId"": ""id2"", + ""lastCheckpoint"": 1000, + ""lastCheckpointUpdatedUtc"": ""2017-07-10T10:39:00Z"", + ""url"": ""http://localhost/projectionStats/id2"" + } + ]")); + } + } + public class When_a_specific_projector_is_requested : GivenSubject + { + public When_a_specific_projector_is_requested() + { + Given(() => + { + var nowUtc = 10.July(2017).At(10, 39).AsUtc(); + var stats = new ProjectionStats(() => nowUtc); + + stats.TrackProgress("id1", 1000); + stats.StoreProperty("id1", "property1", "value1"); + + var appBuilder = new AppBuilder(); + appBuilder.UseLiquidProjections(stats); + + var httpClient = new HttpClient(new OwinHttpMessageHandler(appBuilder.Build())); + httpClient.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + + WithSubject(ct => httpClient); + }); + + When(async () => + { + + return await Subject.GetAsync("http://localhost/projectionStats/id1"); + }); + } + + [Fact] + public async Task Then_it_should_return_the_last_checkpoint_and_properties() + { + var jtoken = JToken.Parse(await Result.Content.ReadAsStringAsync()); + + jtoken.Should().Be(JToken.Parse(@" + { + ""projectorId"": ""id1"", + ""lastCheckpoint"": 1000, + ""lastCheckpointUpdatedUtc"": ""2017-07-10T10:39:00Z"", + ""properties"": [{ + ""key"": ""property1"", + ""value"": ""value1"", + ""lastUpdatedUtc"": ""2017-07-10T10:39:00Z"" + }], + ""eventsUrl"": ""http://localhost/projectionStats/id1/events"" + }")); + } + } + public class When_an_unknown_projector_is_requested : GivenSubject + { + public When_an_unknown_projector_is_requested() + { + Given(() => + { + var nowUtc = 10.July(2017).At(10, 39).AsUtc(); + var stats = new ProjectionStats(() => nowUtc); + + var appBuilder = new AppBuilder(); + appBuilder.UseLiquidProjections(stats); + + var httpClient = new HttpClient(new OwinHttpMessageHandler(appBuilder.Build())); + httpClient.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + + WithSubject(ct => httpClient); + }); + + When(async () => + { + return await Subject.GetAsync("http://localhost/projectionStats/unknown"); + }); + } + + [Fact] + public async Task Then_it_should_return_some_default_information() + { + var jtoken = JToken.Parse(await Result.Content.ReadAsStringAsync()); + + jtoken.Should().Be(JToken.Parse(@" + { + ""projectorId"": ""unknown"", + ""lastCheckpoint"": 0, + ""lastCheckpointUpdatedUtc"": ""2017-07-10T10:39:00Z"", + ""properties"": [], + ""eventsUrl"": ""http://localhost/projectionStats/unknown/events"" + }")); + } + } + public class When_the_events_of_a_specific_projector_are_requested : GivenSubject + { + public When_the_events_of_a_specific_projector_are_requested() + { + Given(() => + { + var nowUtc = 10.July(2017).At(10, 39).AsUtc(); + var stats = new ProjectionStats(() => nowUtc); + stats.LogEvent("id1", "someevent"); + + var appBuilder = new AppBuilder(); + appBuilder.UseLiquidProjections(stats); + + var httpClient = new HttpClient(new OwinHttpMessageHandler(appBuilder.Build())); + httpClient.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + + WithSubject(ct => httpClient); + }); + + When(async () => + { + + return await Subject.GetAsync("http://localhost/projectionStats/id1/events"); + }); + } + + [Fact] + public async Task Then_it_should_return_the_last_checkpoint_and_properties() + { + var jtoken = JToken.Parse(await Result.Content.ReadAsStringAsync()); + + jtoken.Should().Be(JToken.Parse(@" + { + ""projectorId"": ""id1"", + ""events"": [{ + ""body"": ""someevent"", + ""timestampUtc"": ""2017-07-10T10:39:00Z"" + }], + }")); + } + } + public class When_the_eta_to_a_checkpoint_is_requested : GivenSubject + { + public When_the_eta_to_a_checkpoint_is_requested() + { + Given(() => + { + var nowUtc = 10.July(2017).At(10, 39).AsUtc(); + UseThe(new ProjectionStats(() => nowUtc)); + The().TrackProgress("id1", 10); + + nowUtc = nowUtc.Add(1.Minutes()); + The().TrackProgress("id1", 1000); + + var appBuilder = new AppBuilder(); + appBuilder.UseLiquidProjections(The()); + + var httpClient = new HttpClient(new OwinHttpMessageHandler(appBuilder.Build())); + httpClient.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + + WithSubject(ct => httpClient); + }); + + When(async () => + { + return await Subject.GetAsync("http://localhost/projectionStats/id1/eta/2000"); + }); + } + + [Fact] + public async Task Then_it_should_return_the_last_checkpoint_and_properties() + { + TimeSpan eta = The().GetTimeToReach("id1", 2000).Value; + + var jtoken = JToken.Parse(await Result.Content.ReadAsStringAsync()); + JToken element = jtoken.Should().HaveElement("eta").Which; + + element.Should().HaveElement("days").Which.Value().Should().Be(eta.Days); + element.Should().HaveElement("hours").Which.Value().Should().Be(eta.Hours); + element.Should().HaveElement("minutes").Which.Value().Should().Be(eta.Minutes); + element.Should().HaveElement("seconds").Which.Value().Should().Be(eta.Seconds); + element.Should().HaveElement("milliseconds").Which.Value().Should().Be(eta.Milliseconds); + } + } + } +} \ No newline at end of file diff --git a/Tests/LiquidProjections.Specs/app.config b/Tests/LiquidProjections.Specs/app.config new file mode 100644 index 0000000..09cf5f6 --- /dev/null +++ b/Tests/LiquidProjections.Specs/app.config @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Tests/LiquidProjections.Specs/packages.config b/Tests/LiquidProjections.Specs/packages.config index 99ec5eb..60ae98d 100644 --- a/Tests/LiquidProjections.Specs/packages.config +++ b/Tests/LiquidProjections.Specs/packages.config @@ -2,6 +2,11 @@ + + + + + diff --git a/build.cake b/build.cake index 0a021f5..5f295f5 100644 --- a/build.cake +++ b/build.cake @@ -1,5 +1,7 @@ #tool "nuget:?package=xunit.runner.console" #tool "nuget:?package=GitVersion.CommandLine" +#tool "nuget:?package=ILRepack" +#addin "Cake.Incubator" ////////////////////////////////////////////////////////////////////// // ARGUMENTS @@ -33,10 +35,6 @@ Task("GitVersion").Does(() => { }); }); -Task("SyncNugetDependencies").Does(() => { - -}); - Task("Restore-NuGet-Packages") .IsDependentOn("Clean") .Does(() => @@ -50,7 +48,7 @@ Task("Restore-NuGet-Packages") }); Task("Build") - .IsDependentOn("Restore-NuGet-Packages") + .IsDependentOn("GitVersion") .Does(() => { if(IsRunningOnWindows()) @@ -71,6 +69,31 @@ Task("Build") } }); +Task("Merge") + .IsDependentOn("Build") + .Does(() => + { + ILRepack( + "./Artifacts/LiquidProjections.Owin.dll", + "./Src/LiquidProjections.Owin/bin/" + configuration + "/LiquidProjections.Owin.dll", + new FilePath[] { + "./Src/LiquidProjections.Owin/bin/" + configuration + "/Microsoft.Owin.dll", + "./Src/LiquidProjections.Owin/bin/" + configuration + "/Nancy.dll", + "./Src/LiquidProjections.Owin/bin/" + configuration + "/Nancy.Metadata.Modules.dll", + "./Src/LiquidProjections.Owin/bin/" + configuration + "/Nancy.Owin.dll", + "./Src/LiquidProjections.Owin/bin/" + configuration + "/Nancy.Linker.dll", + "./Src/LiquidProjections.Owin/bin/" + configuration + "/Nancy.Swagger.dll", + "./Src/LiquidProjections.Owin/bin/" + configuration + "/Swagger.ObjectModel.dll" + }, + new ILRepackSettings + { + Internalize = true, + XmlDocs = true + }); + + CopyFile("./Artifacts/LiquidProjections.Owin.dll", "./Tests/LiquidProjections.Specs/bin/" + configuration +"/LiquidProjections.Owin.dll"); + }); + Task("Run-Unit-Tests") .Does(() => { @@ -80,7 +103,7 @@ Task("Run-Unit-Tests") Task("Pack") .IsDependentOn("GitVersion") - .IsDependentOn("Build") + .IsDependentOn("Merge") .Does(() => { NuGetPack("./src/LiquidProjections.Abstractions/.nuspec", new NuGetPackSettings { @@ -98,7 +121,15 @@ Task("Pack") { "nugetversion", gitVersion.NuGetVersionV2 } } }); - + + NuGetPack("./src/LiquidProjections.Owin/.nuspec", new NuGetPackSettings { + OutputDirectory = "./Artifacts", + Version = gitVersion.NuGetVersionV2, + Properties = new Dictionary { + { "nugetversion", gitVersion.NuGetVersionV2 } + } + }); + NuGetPack("./src/LiquidProjections.Testing/.nuspec", new NuGetPackSettings { OutputDirectory = "./Artifacts", Version = gitVersion.NuGetVersionV2, @@ -113,8 +144,8 @@ Task("Pack") ////////////////////////////////////////////////////////////////////// Task("Default") - .IsDependentOn("GitVersion") - .IsDependentOn("Build") + .IsDependentOn("Restore-NuGet-Packages") + .IsDependentOn("Merge") .IsDependentOn("Run-Unit-Tests") .IsDependentOn("Pack");