-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add a reactor to reserve and dole out invoice numbers #1
base: main
Are you sure you want to change the base?
Changes from 4 commits
08f0878
5ccfe34
1f6dfa4
688d2df
35238ba
06857d4
1dc7486
aa0bfd1
1f96259
d40134d
ecb2c7e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,4 @@ | ||
obj/ | ||
bin/ | ||
|
||
*.DotSettings.user |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
<Project Sdk="Microsoft.NET.Sdk.Web"> | ||
|
||
<PropertyGroup> | ||
<TargetFramework>net7.0</TargetFramework> | ||
<RootNamespace>eqx_blog</RootNamespace> | ||
</PropertyGroup> | ||
|
||
<ItemGroup> | ||
<Compile Include="CheckpointStore.fs" /> | ||
<Compile Include="InvoiceNumberReactor.fs" /> | ||
<Compile Include="Program.fs" /> | ||
</ItemGroup> | ||
|
||
<ItemGroup> | ||
<PackageReference Include="Equinox" Version="4.0.0-rc.7" /> | ||
<PackageReference Include="Equinox.MessageDb" Version="4.0.0-rc.7" /> | ||
<PackageReference Include="FsCodec" Version="3.0.0-rc.9" /> | ||
<PackageReference Include="FsCodec.SystemTextJson" Version="3.0.0-rc.9" /> | ||
<PackageReference Include="FSharp.UMX" Version="1.1.0" /> | ||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.0" /> | ||
<PackageReference Include="Propulsion" Version="3.0.0-beta.6" /> | ||
<PackageReference Include="Propulsion.MessageDb" Version="3.0.0-beta.5.1" /> | ||
<PackageReference Include="Serilog" Version="2.12.0" /> | ||
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" /> | ||
<PackageReference Include="TypeShape" Version="10.0.0" /> | ||
</ItemGroup> | ||
|
||
<ItemGroup> | ||
<ProjectReference Include="..\InvoiceNumbering\InvoiceNumbering.fsproj" /> | ||
<ProjectReference Include="..\Invoice\Invoice.fsproj" /> | ||
</ItemGroup> | ||
|
||
</Project> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
module CheckpointStore | ||
|
||
open Equinox | ||
open Propulsion.Feed | ||
|
||
module Events = | ||
type Event = | ||
| Checkpoint of {| pos: int64 |} | ||
interface TypeShape.UnionContract.IUnionContract | ||
|
||
let codec = FsCodec.SystemTextJson.Codec.Create<Event>() | ||
|
||
module Fold = | ||
type State = int64 option | ||
let initial = None | ||
|
||
let evolve _ = | ||
function | ||
| Events.Checkpoint x -> Some x.pos | ||
|
||
let fold: State -> Events.Event seq -> State = Seq.fold evolve | ||
|
||
let streamId = StreamId.gen2 SourceId.toString id | ||
type CheckpointService internal (resolve: string -> StreamId -> Equinox.Decider<Events.Event, Fold.State>) = | ||
member _.SetCheckpoint(source, tranche, group: string, pos) = | ||
let category = TrancheId.toString tranche + ":position" | ||
let streamId = streamId (source, group) | ||
let decider = resolve category streamId | ||
decider.Transact (function | ||
| None -> [ Events.Checkpoint {| pos = pos |} ] | ||
| Some curr when curr < pos -> [ Events.Checkpoint {| pos = pos |} ] | ||
| Some _ -> []) | ||
|
||
member _.ReadCheckpoint(source, tranche, group) = | ||
let category = TrancheId.toString tranche + ":position" | ||
let streamId = StreamId.ofRaw $"{SourceId.toString source}_{group}" | ||
let decider = resolve category streamId | ||
decider.Query(id) | ||
|
||
let create resolve = CheckpointService(resolve) | ||
|
||
type CheckpointStore(service: CheckpointService, consumerGroup, defaultCheckpointFrequency) = | ||
interface IFeedCheckpointStore with | ||
member this.Commit(source, tranche, pos) = | ||
service.SetCheckpoint(source, tranche, consumerGroup, Position.toInt64 pos) | ||
|
||
member this.Start(source, tranche, establishOrigin) = | ||
async { | ||
let! maybePos = service.ReadCheckpoint(source, tranche, consumerGroup) | ||
|
||
let! pos = | ||
match maybePos, establishOrigin with | ||
| Some pos, _ -> async { return Position.parse pos } | ||
| None, Some f -> f | ||
| None, None -> async { return Position.initial } | ||
|
||
return struct (defaultCheckpointFrequency, pos) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
module InvoiceNumberReactor | ||
|
||
open System | ||
open Microsoft.Extensions.Hosting | ||
open Propulsion.Internal | ||
open Propulsion.MessageDb | ||
|
||
type HostedService internal (connectionString, log, service: InvoiceNumberingReactor.Service, checkpointService: CheckpointStore.CheckpointService) = | ||
|
||
inherit BackgroundService() | ||
|
||
[<Literal>] | ||
let GroupName = "InvoiceNumberingReactor" | ||
|
||
let categories = [| Invoice.Category |] | ||
|
||
let checkpoints = CheckpointStore.CheckpointStore(checkpointService, GroupName, TimeSpan.FromSeconds 10) | ||
|
||
override _.ExecuteAsync(ct) = | ||
let computation = | ||
async { | ||
use sink = service.Sink(log) | ||
|
||
use src = | ||
MessageDbSource( | ||
log, | ||
statsInterval = TimeSpan.FromMinutes 1, | ||
connectionString = connectionString, | ||
batchSize = 1000, | ||
// Controls the time to wait once fully caught up | ||
// before requesting a new batch of events | ||
tailSleepInterval = TimeSpan.FromMilliseconds 100, | ||
checkpoints = checkpoints, | ||
sink = sink, | ||
// An array of message-db categories to subscribe to | ||
// Propulsion guarantees that events within streams are | ||
// handled in order, it makes no guarantees across streams (Even within categories) | ||
categories = categories | ||
) | ||
.Start() | ||
|
||
do! src.AwaitWithStopOnCancellation() | ||
} | ||
|
||
Async.StartAsTask(computation, cancellationToken = ct) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,37 +1,13 @@ | ||
{ | ||
"iisSettings": { | ||
"windowsAuthentication": false, | ||
"anonymousAuthentication": true, | ||
"iisExpress": { | ||
"applicationUrl": "http://localhost:52842", | ||
"sslPort": 44350 | ||
} | ||
}, | ||
"profiles": { | ||
"http": { | ||
"commandName": "Project", | ||
"dotnetRunMessages": true, | ||
"launchBrowser": true, | ||
"applicationUrl": "http://localhost:5244", | ||
"environmentVariables": { | ||
"ASPNETCORE_ENVIRONMENT": "Development" | ||
} | ||
}, | ||
"https": { | ||
"commandName": "Project", | ||
"dotnetRunMessages": true, | ||
"launchBrowser": true, | ||
"applicationUrl": "https://localhost:7026;http://localhost:5244", | ||
"environmentVariables": { | ||
"ASPNETCORE_ENVIRONMENT": "Development" | ||
} | ||
}, | ||
"IIS Express": { | ||
"commandName": "IISExpress", | ||
"launchBrowser": true, | ||
"environmentVariables": { | ||
"ASPNETCORE_ENVIRONMENT": "Development" | ||
} | ||
} | ||
} | ||
} | ||
{ | ||
"profiles": { | ||
"http": { | ||
"commandName": "Project", | ||
"dotnetRunMessages": true, | ||
"launchBrowser": false, | ||
"applicationUrl": "http://localhost:5244", | ||
"environmentVariables": { | ||
"ASPNETCORE_ENVIRONMENT": "Development" | ||
} | ||
} | ||
} | ||
} |
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't in the mood to add another PG user and schema for the checkpoints. This just checkpoints into an adjacent stream.
e.g. if you're group
MyGroup
listening to the categoryMyCategory
, it'll checkpoint toMyCategory:position-messageDb_MyGroup
. ThemessageDb
comes from propulsion and is to allow for multiple sources.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might upstream this to propulsion
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd not be surprised if @bartelink had already made a storage agnostic decider like this that I'm just not aware of
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The document-based stores (which can do rollingstate updates) do this https://github.com/jet/propulsion/blob/master/src/Propulsion.CosmosStore/ReaderCheckpoint.fs
But an equivalent for PG is not currently implemented (might not be the daftest idea!)
For now I think using https://github.com/jet/propulsion/blob/master/src/Propulsion.MessageDb/ReaderCheckpoint.fs#L49 is simply the right thing to do, even if it makes some mess ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wrt upstreaming, having an ever-growing stream would be a blocker
Also while in general it can be helpful to keep checkpoints in the same place as the info being derived (and that's the case here), another consideration is that you want to avoid the feedback effect of writing a checkpoint position triggering a (null) reaction (and checkpoint update every 5s) each time
For messagedb, if you keep Invoice, InvoiceNumber and the checkpoints in the same DB, [as events] this would be the case
-aux
separated container to avoid this effect$ReaderCheckpoint-{x}
https://github.com/jet/propulsion/blob/1d862e907502d727f5508b401deb85c6c60ef1e7/src/Propulsion.CosmosStore/ReaderCheckpoint.fs#L17 at https://github.com/jet/propulsion/blob/master/src/Propulsion.DynamoStore.Indexer/Handler.fs#L24 (logging issue DynamoStore: Indexer should not index any 'system' streams jet/propulsion#197) to avoid the equivalent (not this is not as critical as it may seem as there is only one event per hour)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's definitely not a bad idea. But as always, there are trade-offs.
I would definitely default to the checkpoints table, but I think there's value in having the option to "just use messagedb"
I don't think this applies to MessageDB as we keep the position on a per category basis. There's no feedback because the checkpoints go to a separate category.
The position of message-db's authors (which I agree with) is that message db should not be in the business of deleting data. Any production deployment of message-db will include pruning procedures outside of the application. With that in mind I don't think it should be a blocker as it's entirely consistent with real-world usage of MessageDB.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not suggesting there should be built-in pruning.
If the position is the event index within the category and not affected by writes to other categories, I guess that will dramatically reduce the amount of checkpointing (but e.g. if you wanted to store ESDB checkpoints in PG, then an event every 5s is going to add up, and inevitably someone will get paged at 5am and get annoyed with @nordfjord when they
git blame
!)Maybe for the sample have the checkpoints DB connstr be optional iff people opt into "unbounded stream" checkpoints ?
IOW if they run with
--checkpointStream
, they can avoid rigging the checkpoints schema ?Maybe I'm being a bit strong - In the context of the sample as a whole, the aggregate does serve as a nice example impl. But I do feel it's a step too far to put it in the box unless it's in a
Internal
namespace with a name that conveys that it will grow forever and you might just want to use the one that keeps one row in the DB ;)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The position is from the global sequence. The position sequence in the category is therefore monotonic but not atomic (e.g. 1,3,8,12222 is a perfectly valid sequence of positions). Also note that the subscription is polling
get_category_messages
for each of the categories supplied (so we'll only get evens for the subscribed categories). This is of course different from how the other stores function where the subscription is to an$all
stream.To be clear, when I say "real-world" I mean that this is how eventide does its checkpointing