Move to netstandard, more tests, tighter domain

This commit is contained in:
Smaug123
2022-10-29 22:15:32 +01:00
parent e77563a5de
commit 969a51b6d2
6 changed files with 175 additions and 94 deletions

View File

@@ -240,3 +240,80 @@ module TestInMemoryServer =
property property
|> Prop.forAll (Arb.fromGen (historyGen 5)) |> Prop.forAll (Arb.fromGen (historyGen 5))
|> Check.QuickThrowOnFailure |> Check.QuickThrowOnFailure
[<Test>]
let ``Heartbeat is rejected if an update hasn't propagated`` () =
let clusterSize = 5
let cluster, network = InMemoryCluster.make<byte> clusterSize
let startupSequence =
[
NetworkAction.InactivityTimeout 1<ServerId>
// Two servers vote for server 1...
NetworkAction.NetworkMessage (2<ServerId>, 0)
NetworkAction.NetworkMessage (3<ServerId>, 0)
// Server 1 processes incoming votes, and achieves majority, electing itself leader!
NetworkAction.NetworkMessage (1<ServerId>, 0)
NetworkAction.NetworkMessage (1<ServerId>, 1)
// and the other votes are processed and discarded
NetworkAction.NetworkMessage (0<ServerId>, 0)
NetworkAction.NetworkMessage (4<ServerId>, 0)
NetworkAction.NetworkMessage (1<ServerId>, 2)
NetworkAction.NetworkMessage (1<ServerId>, 3)
// Get the followers' heartbeat processing out of the way
NetworkAction.NetworkMessage (0<ServerId>, 1)
NetworkAction.NetworkMessage (2<ServerId>, 1)
NetworkAction.NetworkMessage (3<ServerId>, 1)
NetworkAction.NetworkMessage (4<ServerId>, 1)
NetworkAction.NetworkMessage (1<ServerId>, 4)
NetworkAction.NetworkMessage (1<ServerId>, 5)
NetworkAction.NetworkMessage (1<ServerId>, 6)
NetworkAction.NetworkMessage (1<ServerId>, 7)
// Submit data to leader, then heartbeat leader, and process heartbeat on another node.
// This should come through as "follower did not apply leader entry".
// (This is correct: the network has effectively dropped all the leader's AppendEntries messages,
// and the protocol has correctly allowed the followers to recognise that their logs are inconsistent
// with the leader's.)
NetworkAction.ClientRequest (1<ServerId>, byte 3, printfn "processed: %O")
NetworkAction.Heartbeat 1<ServerId>
// Deliver the heartbeat messages.
NetworkAction.NetworkMessage (0<ServerId>, 2)
NetworkAction.NetworkMessage (2<ServerId>, 2)
NetworkAction.NetworkMessage (3<ServerId>, 2)
NetworkAction.NetworkMessage (4<ServerId>, 2)
]
for action in startupSequence do
NetworkAction.perform cluster network action
// The servers have all rejected the heartbeat.
network.UndeliveredMessages 1<ServerId>
|> List.map (fun (_index, message) ->
match message with
| Message.Reply (Reply.AppendEntriesReply reply) -> reply
| _ -> failwithf "Unexpected reply: %+A" message
)
|> shouldEqual
[
{
Success = None
Follower = 0<ServerId>
FollowerTerm = 1<Term>
}
{
Success = None
Follower = 2<ServerId>
FollowerTerm = 1<Term>
}
{
Success = None
Follower = 3<ServerId>
FollowerTerm = 1<Term>
}
{
Success = None
Follower = 4<ServerId>
FollowerTerm = 1<Term>
}
]

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>netstandard2.0</TargetFramework>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>

View File

@@ -83,7 +83,7 @@ type AppendEntriesReply =
| Some index -> sprintf "successfully applied leader entry, log length %i" index | Some index -> sprintf "successfully applied leader entry, log length %i" index
| None -> "did not apply leader entry" | None -> "did not apply leader entry"
sprintf "Follower %i %s" this.FollowerTerm description sprintf "Follower %i (at term %i) %s" this.Follower this.FollowerTerm description
/// I am the leader. Followers, update your state as follows. /// I am the leader. Followers, update your state as follows.
type AppendEntriesMessage<'a> = type AppendEntriesMessage<'a> =
@@ -153,6 +153,7 @@ type Reply =
| RequestVoteReply v -> v.ToString () | RequestVoteReply v -> v.ToString ()
| AppendEntriesReply r -> r.ToString () | AppendEntriesReply r -> r.ToString ()
[<RequireQualifiedAccess>]
type Message<'a> = type Message<'a> =
| Instruction of Instruction<'a> | Instruction of Instruction<'a>
| Reply of Reply | Reply of Reply
@@ -475,12 +476,14 @@ type Server<'a>
| ServerSpecialisation.Candidate _ | ServerSpecialisation.Candidate _
| ServerSpecialisation.Follower _ -> () | ServerSpecialisation.Follower _ -> ()
| ServerSpecialisation.Leader _ -> | ServerSpecialisation.Leader _ ->
let lastLogEntry = persistentState.GetLastLogEntry () |> Option.map snd
for i in 0 .. clusterSize - 1 do for i in 0 .. clusterSize - 1 do
if i * 1<ServerId> <> me then if i * 1<ServerId> <> me then
{ {
LeaderTerm = persistentState.CurrentTerm LeaderTerm = persistentState.CurrentTerm
LeaderId = me LeaderId = me
PrevLogEntry = persistentState.GetLastLogEntry () |> Option.map snd PrevLogEntry = lastLogEntry
NewEntry = None NewEntry = None
LeaderCommitIndex = volatileState.CommitIndex LeaderCommitIndex = volatileState.CommitIndex
ReplyChannel = ReplyChannel =
@@ -585,8 +588,8 @@ type Server<'a>
|> Instruction.RequestVote |> Instruction.RequestVote
|> Message.Instruction |> Message.Instruction
|> messageChannel (i * 1<ServerId>) |> messageChannel (i * 1<ServerId>)
| ServerAction.Receive (Instruction m) -> processMessage m | ServerAction.Receive (Message.Instruction m) -> processMessage m
| ServerAction.Receive (Reply r) -> processReply r | ServerAction.Receive (Message.Reply r) -> processReply r
| ServerAction.Receive (Message.ClientRequest (toAdd, replyChannel)) -> | ServerAction.Receive (Message.ClientRequest (toAdd, replyChannel)) ->
match currentType with match currentType with
| ServerSpecialisation.Leader _ -> | ServerSpecialisation.Leader _ ->

View File

@@ -57,22 +57,52 @@ module Program =
printf "Unrecognised input. " printf "Unrecognised input. "
None None
let rec getHeartbeater (clusterSize : int) (serverId : string) = let rec getHeartbeat (leaders : Set<int<ServerId>>) (clusterSize : int) (serverId : string) =
// TODO: restrict this to the leaders only
match Int32.TryParse serverId with match Int32.TryParse serverId with
| true, serverId -> | true, serverId ->
if serverId >= clusterSize || serverId < 0 then if serverId >= clusterSize || serverId < 0 then
printf "Server ID must be between 0 and %i inclusive. " (clusterSize - 1) printf "Server ID must be between 0 and %i inclusive. " (clusterSize - 1)
None None
else else
Some (serverId * 1<ServerId>) let serverId = serverId * 1<ServerId>
if leaders |> Set.contains serverId then
Some serverId
else
printf "Cannot heartbeat a non-leader. "
None
| false, _ -> | false, _ ->
printf "Unrecognised input. " printf "Unrecognised input. "
None None
let rec getAction (clusterSize : int) = let rec getClientData (clusterSize : int) (s : string) =
let s = s.Trim ()
match s.Split ',' |> List.ofArray with
| serverId :: rest ->
match Int32.TryParse serverId with
| true, serverId ->
if serverId >= clusterSize || serverId < 0 then
printf "Server ID must be between 0 and %i inclusive. " (clusterSize - 1)
None
else
let rest = String.concat "," rest |> fun s -> s.Trim ()
match Byte.TryParse rest with
| true, b -> Some (serverId * 1<ServerId>, b)
| false, _ ->
printfn "Client data must be a byte, e.g. 255, 0, or 43."
None
| false, _ ->
printfn "Server ID expected as first comma-separated component."
None
| _ ->
printfn "Expected server ID and byte, e.g. '3,76'"
None
let rec getAction (leaders : Set<int<ServerId>>) (clusterSize : int) : NetworkAction<byte> =
printf printf
"Enter action. Trigger [t]imeout <server id>, [h]eartbeat a leader <server id>, [d]rop message <server id, message id>, or allow [m]essage <server id, message id>: " "Enter action. Trigger [t]imeout <server id>, [h]eartbeat a leader <server id>, [d]rop message <server id, message id>, [s]ubmit data <server id, byte>, or allow [m]essage <server id, message id>: "
let s = let s =
let rec go () = let rec go () =
@@ -85,22 +115,26 @@ module Program =
| 'T' -> | 'T' ->
match getTimeout clusterSize s.[1..] with match getTimeout clusterSize s.[1..] with
| Some t -> t |> InactivityTimeout | Some t -> t |> InactivityTimeout
| None -> getAction clusterSize | None -> getAction leaders clusterSize
| 'D' -> | 'D' ->
match getMessage clusterSize s.[1..] with match getMessage clusterSize s.[1..] with
| Some m -> m |> DropMessage | Some m -> m |> DropMessage
| None -> getAction clusterSize | None -> getAction leaders clusterSize
| 'M' -> | 'M' ->
match getMessage clusterSize s.[1..] with match getMessage clusterSize s.[1..] with
| Some m -> m |> NetworkMessage | Some m -> m |> NetworkMessage
| None -> getAction clusterSize | None -> getAction leaders clusterSize
| 'H' -> | 'H' ->
match getHeartbeater clusterSize s.[1..] with match getHeartbeat leaders clusterSize s.[1..] with
| Some h -> Heartbeat h | Some h -> Heartbeat h
| None -> getAction clusterSize | None -> getAction leaders clusterSize
| 'S' ->
match getClientData clusterSize s.[1..] with
| Some (server, data) -> ClientRequest (server, data, printfn "%O")
| None -> getAction leaders clusterSize
| _ -> | _ ->
printf "Unrecognised input. " printf "Unrecognised input. "
getAction clusterSize getAction leaders clusterSize
let electLeader = let electLeader =
[ [
@@ -117,7 +151,7 @@ module Program =
[<EntryPoint>] [<EntryPoint>]
let main _argv = let main _argv =
let clusterSize = 5 let clusterSize = 5
let cluster, network = InMemoryCluster.make<int> clusterSize let cluster, network = InMemoryCluster.make<byte> clusterSize
let startupSequence = let startupSequence =
[ [
@@ -150,6 +184,9 @@ module Program =
NetworkAction.NetworkMessage (0<ServerId>, 2) NetworkAction.NetworkMessage (0<ServerId>, 2)
NetworkAction.NetworkMessage (1<ServerId>, 6) NetworkAction.NetworkMessage (1<ServerId>, 6)
] ]
|> ignore
[]
for action in startupSequence do for action in startupSequence do
NetworkAction.perform cluster network action NetworkAction.perform cluster network action
@@ -158,11 +195,24 @@ module Program =
printNetworkState network printNetworkState network
printClusterState cluster printClusterState cluster
let action = getAction clusterSize let leaders =
Seq.init
clusterSize
(fun i ->
let i = i * 1<ServerId>
i, cluster.State i
)
|> Seq.choose (fun (i, status) ->
match status with
| Leader _ -> Some i
| _ -> None
)
|> Set.ofSeq
let action = getAction leaders clusterSize
NetworkAction.perform cluster network action NetworkAction.perform cluster network action
// TODO: log out the committed state so that we can see whether the leader is correctly // TODO: log out the committed state so that we can see whether the leader is correctly
// processing heartbeat responses // processing heartbeat responses
// TODO: allow client queries!
0 0

View File

@@ -23,8 +23,8 @@ do
serverHeading.textContent <- "Server" serverHeading.textContent <- "Server"
statusHeading.textContent <- "Status" statusHeading.textContent <- "Status"
row.appendChild (serverHeading) |> ignore row.appendChild serverHeading |> ignore
row.appendChild (statusHeading) |> ignore row.appendChild statusHeading |> ignore
serverStatuses.appendChild row |> ignore serverStatuses.appendChild row |> ignore
let serverStatusNodes = let serverStatusNodes =
@@ -33,8 +33,8 @@ let serverStatusNodes =
let child = document.createElement "td" :?> Browser.Types.HTMLTableCellElement let child = document.createElement "td" :?> Browser.Types.HTMLTableCellElement
let statusCell = document.createElement "td" :?> Browser.Types.HTMLTableCellElement let statusCell = document.createElement "td" :?> Browser.Types.HTMLTableCellElement
child.textContent <- sprintf "%i" i child.textContent <- sprintf "%i" i
node.appendChild (child) |> ignore node.appendChild child |> ignore
node.appendChild (statusCell) |> ignore node.appendChild statusCell |> ignore
serverStatuses.appendChild node |> ignore serverStatuses.appendChild node |> ignore
statusCell statusCell
|> List.init clusterSize |> List.init clusterSize
@@ -51,7 +51,7 @@ let resetButtonArea () =
for i in 0 .. clusterSize - 1 do for i in 0 .. clusterSize - 1 do
let heading = document.createElement "th" :?> Browser.Types.HTMLTableCellElement let heading = document.createElement "th" :?> Browser.Types.HTMLTableCellElement
heading.innerText <- sprintf "Server %i" i heading.innerText <- sprintf "Server %i" i
headerRow.appendChild (heading) |> ignore headerRow.appendChild heading |> ignore
messageQueueArea.appendChild headerRow |> ignore messageQueueArea.appendChild headerRow |> ignore
@@ -67,7 +67,7 @@ let printClusterState<'a> (cluster : Cluster<'a>) : unit =
for i in 0 .. cluster.ClusterSize - 1 do for i in 0 .. cluster.ClusterSize - 1 do
serverStatusNodes.[i].textContent <- cluster.State (i * 1<ServerId>) |> string<ServerStatus> serverStatusNodes.[i].textContent <- cluster.State (i * 1<ServerId>) |> string<ServerStatus>
let cluster, network = InMemoryCluster.make<int> clusterSize let cluster, network = InMemoryCluster.make<string> clusterSize
let performWithoutPrintingNetworkState action = let performWithoutPrintingNetworkState action =
NetworkAction.perform cluster network action NetworkAction.perform cluster network action
@@ -156,42 +156,8 @@ let rec getHeartbeater (clusterSize : int) (serverId : string) =
printf "Unrecognised input. " printf "Unrecognised input. "
None None
(*
let rec getAction (clusterSize : int) =
printf
"Enter action. Trigger [t]imeout <server id>, [h]eartbeat a leader <server id>, [d]rop message <server id, message id>, or allow [m]essage <server id, message id>: "
let s =
let rec go () =
let s = Console.ReadLine().ToUpperInvariant ()
if String.IsNullOrEmpty s then go () else s
go ()
match s.[0] with
| 'T' ->
match getTimeout clusterSize s.[1..] with
| Some t -> t |> InactivityTimeout
| None -> getAction clusterSize
| 'D' ->
match getMessage clusterSize s.[1..] with
| Some m -> m |> DropMessage
| None -> getAction clusterSize
| 'M' ->
match getMessage clusterSize s.[1..] with
| Some m -> m |> NetworkMessage
| None -> getAction clusterSize
| 'H' ->
match getHeartbeater clusterSize s.[1..] with
| Some h -> Heartbeat h
| None -> getAction clusterSize
| _ ->
printf "Unrecognised input. "
getAction clusterSize
*)
let startupText = let startupText =
document.querySelector (".startup-text") :?> Browser.Types.HTMLParagraphElement document.querySelector ".startup-text" :?> Browser.Types.HTMLParagraphElement
startupText.textContent <- "Starting up..." startupText.textContent <- "Starting up..."
@@ -231,16 +197,16 @@ let startupSequence =
|> fun p -> p.``then`` (fun () -> startupText.textContent <- "Started! Press buttons.") |> fun p -> p.``then`` (fun () -> startupText.textContent <- "Started! Press buttons.")
let timeoutButton = let timeoutButton =
document.querySelector (".timeout-button") :?> Browser.Types.HTMLButtonElement document.querySelector ".timeout-button" :?> Browser.Types.HTMLButtonElement
let timeoutField = let timeoutField =
document.querySelector (".timeout-text") :?> Browser.Types.HTMLInputElement document.querySelector ".timeout-text" :?> Browser.Types.HTMLInputElement
timeoutField.max <- string<int> (clusterSize - 1) timeoutField.max <- string<int> (clusterSize - 1)
timeoutField.min <- "0" timeoutField.min <- "0"
timeoutButton.onclick <- timeoutButton.onclick <-
fun evt -> fun _event ->
startupSequence.``then`` (fun () -> startupSequence.``then`` (fun () ->
timeoutField.valueAsNumber timeoutField.valueAsNumber
|> int |> int
@@ -252,16 +218,16 @@ timeoutButton.onclick <-
) )
let heartbeatButton = let heartbeatButton =
document.querySelector (".heartbeat-button") :?> Browser.Types.HTMLButtonElement document.querySelector ".heartbeat-button" :?> Browser.Types.HTMLButtonElement
let heartbeatField = let heartbeatField =
document.querySelector (".heartbeat-text") :?> Browser.Types.HTMLInputElement document.querySelector ".heartbeat-text" :?> Browser.Types.HTMLInputElement
heartbeatField.max <- string<int> (clusterSize - 1) heartbeatField.max <- string<int> (clusterSize - 1)
heartbeatField.min <- "0" heartbeatField.min <- "0"
heartbeatButton.onclick <- heartbeatButton.onclick <-
fun evt -> fun _event ->
startupSequence.``then`` (fun () -> startupSequence.``then`` (fun () ->
heartbeatField.valueAsNumber heartbeatField.valueAsNumber
|> int |> int
@@ -271,16 +237,3 @@ heartbeatButton.onclick <-
printNetworkState network printNetworkState network
) )
//let electLeader =
// [
// NetworkAction.InactivityTimeout 0<ServerId>
// NetworkAction.NetworkMessage (1<ServerId>, 0)
// NetworkAction.NetworkMessage (2<ServerId>, 0)
// NetworkAction.DropMessage (3<ServerId>, 0)
// NetworkAction.DropMessage (4<ServerId>, 0)
// NetworkAction.NetworkMessage (0<ServerId>, 0)
// NetworkAction.NetworkMessage (0<ServerId>, 1)
// // At this point, server 0 is leader in an uncontested election.
// ]

View File

@@ -17,19 +17,17 @@
flake-utils, flake-utils,
... ...
}: }:
flake-utils.lib.eachDefaultSystem ( flake-utils.lib.eachSystem [flake-utils.lib.system.aarch64-darwin] (system: let
system: let pkgs = nixpkgs.legacyPackages.${system};
pkgs = nixpkgs.legacyPackages.${system}; in {
in { devShells.default = pkgs.mkShell {
devShell = pkgs.mkShell { buildInputs =
buildInputs = [alejandra.defaultPackage.${system} pkgs.nodejs-14_x pkgs.dotnet-sdk_6]
[alejandra.defaultPackage.${system} pkgs.nodejs-14_x pkgs.dotnet-sdk_6] ++ (
++ ( if pkgs.stdenv.isDarwin
if pkgs.stdenv.isDarwin then [pkgs.darwin.apple_sdk.frameworks.CoreServices]
then [pkgs.darwin.apple_sdk.frameworks.CoreServices] else []
else [] );
); };
}; });
}
);
} }