Thought I'd write something about the pitfalls that I've stumbled across when using my event sourcing engine.
This post is part of a series:
Functional Adventures in F# - A simple planner
Functional Adventures in F# - Calling F# from C#
Functional Adventures in F# - Using Map Collections
Functional Adventures in F# - Application State
Functional Adventures in F# - Types with member functions
Functional Adventures in F# - Getting rid of loops
Functional Adventures in F# - Getting rid of temp variables
Functional Adventures in F# - The MailboxProcessor
Functional Adventures in F# - Persisting Application State
Functional Adventures in F# - Adding Snapshot Persisting
Functional Adventures in F# - Type-safe identifiers
Functional Adventures in F# - Event sourcing lessons learned
Using domain objects in actions
The first actions that I used basically had me create the domain objects on the outside and then plug them into the create action. The store only took the object and placed it in the stores Map over objects.type AddUser = { ActionId: Guid; User : User } type UserStore = { Users: Map<UserId, User> } member this.AddUser (action:AddUser)= { this with Users = this.Users.Add(action.User.Id, action.User); } member this.HandleAction (action:obj) = match action with | :? AddUser as a -> this.AddUser a | _ -> thisThis makes your domain model a part of the persistence layer (as all actions are persisted). I.e. you cannot change your model without getting nifty errors (like null exceptions)
You might think that: You still persist snapshots of the whole ApplicationState, i.e. the User object ends up in persistence.
True. But when you do 'model breaking changes', you could just remove all snapshots and then rerun all actions from the start and get the equivalent state as you did before the breaking change. If you in turn include the domain object in the actions, you are screwed.
A better way to write this would be:
type AddUser = { ActionId: Guid; UserId : UserId; Name:string; Email:string } type UserStore = { Users: Map<UserId, User> } member this.AddUser (action:AddUser)= let user = { Id = action.UserId Name = action.Name Email = action.Name } { this with Users = this.Users.Add(user.Id, user); }
Creating identifiers in stores
When creating a new object in a store based on an action, at times I forget and do thing like this:member this.AddUser (action:AddUser)= let user = { Id = UserId (Guid.NewGuid()) Name = action.Name Email = action.Name } { this with Users = this.Users.Add(user.Id, user); }The end result works the first time. But if you restart the service and Replay actions, then you will end up with a different Identifier for the User object in this case. All actions that want to use this object and refer to it by its Identifier, will not find a match after a replay. Not a good thing
A better way to write this would be:
type AddUser = { ActionId: Guid; UserId : UserId; Name:string; Email:string } type UserStore = { Users: Map<UserId, User> } member this.AddUser (action:AddUser)= let user = { Id = action.UserId Name = action.Name Email = action.Name } { this with Users = this.Users.Add(user.Id, user); }This way, when we want to create a new user, the code that creates the AddUser action (maybe your ASP.NET Core MVC Controller, needs to generate the Id, but in turn the Id will be persisted with the action and will be the same even after a replay
Assuming that things exist in the store
Not all things are eternal. So just because there is an action arriving that thinks that there exists an object in the store does not mean that there actually is one. Another action could have removed it between that the first action was created and processed.member this.ModifyUser (action:ModifyUser)= let original = this.Users.[action.UserId] let updated = { original with Name = action.Name; Email = action.Email } { this with Users = this.Users.Add(updated.Id, updated); }The code above would throw an KeyNotFoundException if the key doesn't exist in the Map.
A better way to write this would be to try to find the object.
member this.ModifyUser (action:ModifyUser)= let o = this.Users.TryFind action.UserId match o with | None -> this | Some original -> let updated = { original with Name = action.Name; Email = action.Email } { this with Users = this.Users.Add(updated.Id, updated); }Here we use the Map.TryFind function that returns None or Some x. If the key doesn't exist and we receive None, we jut return the current state as the new state.
If we got an hit, the Some original case would be hit and we can plug in the original code there.
Is it an event or a query?
I'm writing a game... Is each frame a new event that recalculates all positions of game objects... In the first version that was the case.type ShipUpdateCoordinates = { ActionId: Guid; Timestamp:DateTime; ShipId: ShipId; NewPosition:Vector3; }Turns out that in 60 fps, there will be a lot of events persisted.
So. Instead of updating the position of each object in 60 fps. Then only event that is persisted is based on user input (or when the game engine decides to change the direction of an NPC object)
type ShipFlyTo = { ActionId: Guid; Timestamp:DateTime; ShipId: ShipId; Origin:Vector3; Target:Vector3; }The rendering engine will query
type TravelPlan = { Origin : Vector3 Target : Vector3 SpeedMetersPerSecond : float TravelStarted : DateTime } member this.Distance () = this.Origin.distance this.Target member this.TravelTimeSeconds () = match this.SpeedMetersPerSecond with | 0. -> 0. | _ -> this.Distance() / this.SpeedMetersPerSecond member this.Eta () = match this.TravelTimeSeconds() with | 0. -> DateTime.UtcNow | a -> this.TravelStarted.AddSeconds(a) member this.GetCoordinates (now:DateTime) = let eta = this.Eta() let percentage = PercentageOfValueBetweenMinAndMax (float this.TravelStarted.Ticks) (float eta.Ticks) (float now.Ticks) this.Origin.lerp this.Target (percentage/100.)I.e. we know then the event occurred and what time it is now. That way we can calculate the current position of the object based on its speed and assuming it is traveling at constant speed by using linear interpolation (lerp)
The rendering engine can query the model as many times per second as it wants without having to store the new coordinates. As the ApplicationState is immutable and lock-free, we can do this without affecting event processing.
First time start?
I wanted to generate a game world on the first time start. I tried to use the processed events counter. Turns out as all processing is running on a different thread (as a MailboxProcessor), there is a race condition. And sometimes we just generate 2 worlds.. Not a good thingHence I updated the AppHolder class
let mutable private hasReplayed : bool = false; let IsFirstTimeUse() = not hasReplayed let private HandleReplayAction (action:obj, id:Guid) = hasReplayed <- true Processor.Post (Replay action)This way, in my Program.cs that handles the startup I can just write this to ensure that the game world is generated on the first use.
AppHolder.InitiateFromLastSnapshot(); if (AppHolder.IsFirstTimeUse()) GameBasicDataCreator.CreateOnFirstTimeStart();
When do I start the Game engine
A similar question, but took me too long to figure out it even happened.AppHolder.InitiateFromLastSnapshot(); if (AppHolder.IsFirstTimeUse()) GameBasicDataCreator.Create(); _engine.Start();Here, we start the game engine when there is a race condition that Replay events are still being processed on another thread. The game engine will start analyzing the game state and reacting to things it see. I.e. not a good thing
Another change was needed in the AppHolder to fix this
let mutable private replayCompleted : bool = false; let IsReplayCompleted() = replayCompleted type Message = | Snapshot of AppliationState | Replay of obj | ReplayCompleted | Action of objFor starters we add new public function so that we can query if the Replay is completed. We also add a new internal Message union case ReplayCompleted.
let private Processor = MailboxProcessor.Start(fun inbox -> let rec loop (s : AppliationState, c : int) = async { let! message = inbox.Receive() let c' = c + 1 let s' = match message with | Snapshot snapshot -> snapshot | Replay a -> s.HandleAction a | ReplayCompleted -> replayCompleted <- true s | Action a -> AppPersister.Persist a s c' s.HandleAction a state <- s' counter <- c' return! loop (s', c') } loop (AppliationState.Default, 0))Next we change our MailboxProcessor code to include the handling for the new union case. We just set the mutable value to true from false.
let InitiateFromLastSnapshot () = let (snapshot, actions) = AppPersister.GetLatestSnapshotAndActions() match snapshot with | Some x -> Processor.Post (Snapshot x) | _ -> () Array.map (fun x -> (HandleReplayAction x)) actions |> ignore if hasReplayed then Processor.Post ReplayCompleted let InitiateFromActionsOnly () = AppPersister.GetAllActions() |> Array.map (fun x -> (HandleReplayAction x)) |> ignore if hasReplayed then Processor.Post ReplayCompletedAnd lastly, we change our 2 initialize functions to send in the ReplayCompleted message to the MailboxProcessor if there were any Replayed actions at all
In our initialization code in Program.cs we can now add some extra checks if there was any replaying done so that we actually wait for them all to be completed before continuing
AppHolder.InitiateFromLastSnapshot(); if (!AppHolder.IsFirstTimeUse()) { while (!AppHolder.IsReplayCompleted()) Thread.Sleep(100); }
That is it!
AppHolder updates have been uploaded to github.
All code provided as-is. This is copied from my own code-base, May need some additional programming to work. Use for whatever you want, how you want! If you find this helpful, please leave a comment or share a link, not required but appreciated! :)
Hope this helps someone out there!
No comments:
Post a Comment