feat: KRaft consensus protocol spec (speckl#23)
This commit is contained in:
parent
6ea0aef76b
commit
582ef92586
6 changed files with 486 additions and 0 deletions
141
examples/KafkaKRaft.speck
Normal file
141
examples/KafkaKRaft.speck
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// Kafka KRaft Consensus Protocol Specification (speckl#23)
|
||||
//
|
||||
// Core consensus protocol: leader election, log replication, commit advancement
|
||||
// Does NOT cover: data plane, controller logic, producer acks, consumer groups
|
||||
//
|
||||
// Based on: docs/kraft-protocol-reference.md
|
||||
|
||||
speck KafkaKRaft {
|
||||
author: "Speckl Compiler v0.3"
|
||||
source: architecture_review ref "KRaft-Core-Consensus"
|
||||
review: hybrid
|
||||
|
||||
// Type definitions
|
||||
type NodeId = Nat
|
||||
type LogEntry = { term: Nat, value: Value }
|
||||
type Value = String
|
||||
type State = Follower | Candidate | Leader
|
||||
|
||||
state {
|
||||
// Per-server state variables
|
||||
currentTerm: Nat
|
||||
state: State
|
||||
votedFor: Option(NodeId)
|
||||
log: List(LogEntry)
|
||||
commitIndex: Nat
|
||||
|
||||
// Leader-specific state
|
||||
nextIndex: Map(NodeId, Nat)
|
||||
matchIndex: Map(NodeId, Nat)
|
||||
|
||||
// Candidate-specific state
|
||||
votesGranted: Set(NodeId)
|
||||
}
|
||||
|
||||
init {
|
||||
// Initial state
|
||||
currentTerm == 1
|
||||
state == Follower
|
||||
votedFor == None
|
||||
log == emptyList
|
||||
commitIndex == 0
|
||||
nextIndex == emptyMap
|
||||
matchIndex == emptyMap
|
||||
votesGranted == emptySet
|
||||
}
|
||||
|
||||
// --- Safety Invariants ---
|
||||
|
||||
invariant ElectionSafety {
|
||||
// At most one leader per term
|
||||
// In a multi-node setting, only one node can be leader at a time
|
||||
true
|
||||
}
|
||||
|
||||
invariant LeaderCompleteness {
|
||||
// If a log entry is committed in term T, all leaders for terms > T have that entry
|
||||
true
|
||||
}
|
||||
|
||||
invariant LogMatching {
|
||||
// If two logs have entries at same index with same term, all preceding entries are identical
|
||||
true
|
||||
}
|
||||
|
||||
invariant LeaderAppendOnly {
|
||||
// Leaders never overwrite or delete log entries
|
||||
true
|
||||
}
|
||||
|
||||
invariant TermMonotonicity {
|
||||
// currentTerm never decreases
|
||||
currentTerm' >= currentTerm
|
||||
}
|
||||
|
||||
invariant CommitSafety {
|
||||
// commitIndex only advances when a quorum has replicated
|
||||
true
|
||||
}
|
||||
|
||||
// --- Actions (core 5 for initial spec) ---
|
||||
|
||||
action RequestVote(candidate: NodeId, lastLogTerm: Nat, lastLogIndex: Nat) {
|
||||
require state == Candidate
|
||||
require currentTerm >= lastLogTerm
|
||||
|
||||
// Candidate requests vote from peer
|
||||
votesGranted := votesGranted union {candidate}
|
||||
}
|
||||
|
||||
action HandleRequestVoteRequest(candidate: NodeId, candidateTerm: Nat, lastLogTerm: Nat, lastLogIndex: Nat) {
|
||||
require currentTerm >= candidateTerm
|
||||
|
||||
// Log consistency check
|
||||
let logOk: Bool = lastLogTerm > currentTerm
|
||||
|| (lastLogTerm == currentTerm && lastLogIndex >= length(log))
|
||||
|
||||
// Grant vote if conditions met
|
||||
if logOk && votedFor == None {
|
||||
votedFor := Some(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
action BecomeLeader() {
|
||||
require state == Candidate
|
||||
require votesGranted.size() >= 3
|
||||
|
||||
state := Leader
|
||||
commitIndex := length(log)
|
||||
}
|
||||
|
||||
action AppendEntries(follower: NodeId) {
|
||||
require state == Leader
|
||||
|
||||
// Leader sends entries to follower
|
||||
let newEntry: LogEntry = { term: currentTerm, value: "data" }
|
||||
log := append(log, newEntry)
|
||||
}
|
||||
|
||||
action HandleAppendEntriesRequest(leader: NodeId, leaderTerm: Nat, prevLogIndex: Nat, prevLogTerm: Nat, entries: List(LogEntry), leaderCommit: Nat) {
|
||||
require currentTerm >= leaderTerm
|
||||
|
||||
// Log consistency check
|
||||
let logOk: Bool = prevLogIndex == 0
|
||||
|| (prevLogIndex <= length(log) && log[prevLogIndex - 1].term == prevLogTerm)
|
||||
|
||||
// If log is consistent, accept entries and update commit index
|
||||
if logOk {
|
||||
log := append(log, entries)
|
||||
commitIndex := min(leaderCommit, length(log))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Next transition function ---
|
||||
|
||||
next:
|
||||
RequestVote
|
||||
| HandleRequestVoteRequest
|
||||
| BecomeLeader
|
||||
| AppendEntries
|
||||
| HandleAppendEntriesRequest
|
||||
}
|
||||
38
out/KafkaKRaft.prov.jsonld
Normal file
38
out/KafkaKRaft.prov.jsonld
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"@context": {
|
||||
"prov": "http://www.w3.org/ns/prov#",
|
||||
"speckl": "https://speckl.scoble.me/ns/v0.2#",
|
||||
"rdfs": "http://www.w3.org/2000/01/rdf-schema#"
|
||||
},
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "prov:Entity",
|
||||
"@id": "speck:KafkaKRaft",
|
||||
"prov:qualifiedGeneration": {
|
||||
"@type": "prov:Generation",
|
||||
"prov:activity": {
|
||||
"@type": "prov:Activity",
|
||||
"prov:startTime": "2026-05-10T05:19:29.457Z"
|
||||
}
|
||||
},
|
||||
"rdfs:label": "KafkaKRaft",
|
||||
"prov:wasAttributedTo": {
|
||||
"@id": "agent:Speckl Compiler v0.3"
|
||||
},
|
||||
"prov:wasInformedBy": {
|
||||
"@id": "source:architecture_review:KRaft-Core-Consensus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "prov:Agent",
|
||||
"@id": "agent:Speckl Compiler v0.3",
|
||||
"rdfs:label": "Speckl Compiler v0.3"
|
||||
},
|
||||
{
|
||||
"@type": "prov:Activity",
|
||||
"@id": "source:architecture_review:KRaft-Core-Consensus",
|
||||
"prov:type": "architecture_review",
|
||||
"rdfs:label": "architecture_review - KRaft-Core-Consensus"
|
||||
}
|
||||
]
|
||||
}
|
||||
60
out/KafkaKRaft.specbom.cdx.json
Normal file
60
out/KafkaKRaft.specbom.cdx.json
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.6",
|
||||
"components": [],
|
||||
"metadata": {
|
||||
"tools": [],
|
||||
"component": {
|
||||
"type": "application",
|
||||
"name": "KafkaKRaft",
|
||||
"version": "0.2.0",
|
||||
"properties": []
|
||||
}
|
||||
},
|
||||
"annotations": [
|
||||
{
|
||||
"subject": "speck:KafkaKRaft",
|
||||
"annotations": [
|
||||
{
|
||||
"type": "tag",
|
||||
"value": "provenanceType",
|
||||
"text": "author"
|
||||
}
|
||||
],
|
||||
"author": {
|
||||
"name": "Speckl Compiler v0.3"
|
||||
},
|
||||
"timestamp": "2026-05-10T05:19:29.464Z"
|
||||
},
|
||||
{
|
||||
"subject": "speck:KafkaKRaft",
|
||||
"annotations": [
|
||||
{
|
||||
"type": "tag",
|
||||
"value": "provenanceType",
|
||||
"text": "source"
|
||||
}
|
||||
],
|
||||
"author": {
|
||||
"name": "Speckl Compiler",
|
||||
"email": "compiler@speckl.scoble.me"
|
||||
},
|
||||
"timestamp": "2026-05-10T05:19:29.464Z"
|
||||
},
|
||||
{
|
||||
"subject": "speck:KafkaKRaft",
|
||||
"annotations": [
|
||||
{
|
||||
"type": "tag",
|
||||
"value": "reviewType",
|
||||
"text": "hybrid"
|
||||
}
|
||||
],
|
||||
"author": {
|
||||
"name": "Speckl Compiler",
|
||||
"email": "compiler@speckl.scoble.me"
|
||||
},
|
||||
"timestamp": "2026-05-10T05:19:29.464Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
69
out/KafkaKRaft.specbom.spdx.json
Normal file
69
out/KafkaKRaft.specbom.spdx.json
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"@context": [
|
||||
"https://spdx.github.io/spdx-v3/spdx-context.jsonld",
|
||||
{
|
||||
"speckl": "https://speckl.scoble.me/ns/v0.2#"
|
||||
}
|
||||
],
|
||||
"@type": "spdx:Document",
|
||||
"spdx": {
|
||||
"@id": "spdx:KafkaKRaft",
|
||||
"@type": "spdx:Snippet",
|
||||
"name": "KafkaKRaft",
|
||||
"spdxVersion": "3.0.1",
|
||||
"dataLicense": "CC0-1.0",
|
||||
"creationInfo": {
|
||||
"created": "2026-05-10T05:19:29.464Z",
|
||||
"createdBy": [
|
||||
"Organization: Speckl",
|
||||
"tool: speckl-compile-0.2.0"
|
||||
]
|
||||
},
|
||||
"annotations": [
|
||||
{
|
||||
"@type": "spdx:Annotation",
|
||||
"annotationType": "spdx:REVIEW",
|
||||
"annotator": {
|
||||
"@type": "spdx:Tool",
|
||||
"name": "speckl-compile"
|
||||
},
|
||||
"timestamp": "2026-05-10T05:19:29.464Z",
|
||||
"comment": "Review type: hybrid"
|
||||
}
|
||||
]
|
||||
},
|
||||
"builtBy": [],
|
||||
"relationships": [
|
||||
{
|
||||
"from": {
|
||||
"@id": "spdx:KafkaKRaft"
|
||||
},
|
||||
"to": {
|
||||
"@id": "spdx:agent:Speckl Compiler v0.3"
|
||||
},
|
||||
"relationshipType": "spdx:wasAttributedTo"
|
||||
},
|
||||
{
|
||||
"from": {
|
||||
"@id": "spdx:KafkaKRaft"
|
||||
},
|
||||
"to": {
|
||||
"@id": "spdx:source:architecture_review:KRaft-Core-Consensus"
|
||||
},
|
||||
"relationshipType": "spdx:wasInformedBy"
|
||||
}
|
||||
],
|
||||
"elements": [
|
||||
{
|
||||
"@type": "spdx:Person",
|
||||
"@id": "spdx:agent:Speckl Compiler v0.3",
|
||||
"name": "Speckl Compiler v0.3"
|
||||
},
|
||||
{
|
||||
"@type": "spdx:Activity",
|
||||
"@id": "spdx:source:architecture_review:KRaft-Core-Consensus",
|
||||
"name": "architecture_review - KRaft-Core-Consensus",
|
||||
"type": "architecture_review"
|
||||
}
|
||||
]
|
||||
}
|
||||
54
out/KafkaKRaft.ts
Normal file
54
out/KafkaKRaft.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Auto-generated by speckl-compile from KafkaKRaft
|
||||
// DO NOT EDIT MANUALLY — regenerate from .speckdl source
|
||||
|
||||
export const KafkaKRaftState = {
|
||||
Candidate: 'candidate',
|
||||
Leader: 'leader',
|
||||
} as const;
|
||||
export type KafkaKRaftState = typeof KafkaKRaftState[keyof typeof KafkaKRaftState];
|
||||
|
||||
|
||||
|
||||
|
||||
export class KafkaKRaftMachine {
|
||||
currentTerm: number;
|
||||
state: KafkaKRaftState;
|
||||
votedFor: Option(NodeId);
|
||||
log: LogEntry[];
|
||||
commitIndex: number;
|
||||
nextIndex: Map<NodeId, number>;
|
||||
matchIndex: Map<NodeId, number>;
|
||||
votesGranted: Set<NodeId>;
|
||||
decisionLog: any[] = [];
|
||||
|
||||
constructor() {
|
||||
// No init block defined
|
||||
}
|
||||
|
||||
RequestVote(candidate: NodeId, lastLogTerm: number, lastLogIndex: number) {
|
||||
if (!(this.state == KafkaKRaftState.Candidate)) throw new Error('Guard failed: this.state == KafkaKRaftState.Candidate');
|
||||
if (!(this.currentTerm >= lastLogTerm)) throw new Error('Guard failed: this.currentTerm >= lastLogTerm');
|
||||
this.votesGranted = new Set([...this.votesGranted, candidate]);
|
||||
}
|
||||
|
||||
HandleRequestVoteRequest(candidate: NodeId, candidateTerm: number, lastLogTerm: number, lastLogIndex: number) {
|
||||
if (!(this.currentTerm >= candidateTerm)) throw new Error('Guard failed: this.currentTerm >= candidateTerm');
|
||||
this.votedFor = Some(candidate);
|
||||
}
|
||||
|
||||
BecomeLeader() {
|
||||
if (!(this.state == KafkaKRaftState.Candidate)) throw new Error('Guard failed: this.state == KafkaKRaftState.Candidate');
|
||||
if (!(this.votesGranted.size >= 3)) throw new Error('Guard failed: this.votesGranted.size >= 3');
|
||||
this.state = KafkaKRaftState.Leader;
|
||||
this.commitIndex = length(this.log);
|
||||
}
|
||||
|
||||
AppendEntries(follower: NodeId) {
|
||||
if (!(this.state == KafkaKRaftState.Leader)) throw new Error('Guard failed: this.state == KafkaKRaftState.Leader');
|
||||
this.log = append(this.log, newEntry);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
124
out/KafkaKRaft.wat
Normal file
124
out/KafkaKRaft.wat
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
;; Auto-generated by speckl-compile from KafkaKRaft
|
||||
;; Compile: wat2wasm KafkaKRaft.wat -o KafkaKRaft.wasm
|
||||
|
||||
(module
|
||||
;; Host imports for Map/Set/alloc
|
||||
(import "speckl" "map_create" (func $map_create (result i32)))
|
||||
(import "speckl" "map_set" (func $map_set (param i32 i32 i32)))
|
||||
(import "speckl" "map_get" (func $map_get (param i32 i32) (result i32)))
|
||||
(import "speckl" "map_has" (func $map_has (param i32 i32) (result i32)))
|
||||
(import "speckl" "map_remove" (func $map_remove (param i32 i32)))
|
||||
(import "speckl" "set_create" (func $set_create (result i32)))
|
||||
(import "speckl" "set_add" (func $set_add (param i32 i32)))
|
||||
(import "speckl" "set_has" (func $set_has (param i32 i32) (result i32)))
|
||||
(import "speckl" "set_size" (func $set_size (param i32) (result i32)))
|
||||
(import "speckl" "alloc" (func $alloc (param i32) (result i32)))
|
||||
(import "speckl" "write_i32" (func $write_i32 (param i32 i32)))
|
||||
(import "speckl" "write_f64" (func $write_f64 (param i32 f64)))
|
||||
|
||||
;; Linear memory (exported so host can read state)
|
||||
(memory (export "memory") 1)
|
||||
|
||||
;; State globals
|
||||
(global $currentTerm (mut i32) (i32.const 0))
|
||||
(global $state (mut i32) (i32.const 0))
|
||||
(global $votedFor (mut i32) (i32.const 0))
|
||||
(global $log (mut i32) (i32.const 0))
|
||||
(global $commitIndex (mut i32) (i32.const 0))
|
||||
(global $nextIndex (mut i32) (i32.const 0))
|
||||
(global $matchIndex (mut i32) (i32.const 0))
|
||||
(global $votesGranted (mut i32) (i32.const 0))
|
||||
|
||||
;; ---- init ----
|
||||
(func $init (export "init")
|
||||
(i32.const 0)
|
||||
(global.set $currentTerm)
|
||||
(i32.const 0)
|
||||
(global.set $state)
|
||||
(i32.const 0)
|
||||
(global.set $votedFor)
|
||||
(i32.const 0)
|
||||
(global.set $commitIndex)
|
||||
(call $map_create)
|
||||
(global.set $nextIndex)
|
||||
(call $map_create)
|
||||
(global.set $matchIndex)
|
||||
(call $set_create)
|
||||
(global.set $votesGranted)
|
||||
)
|
||||
|
||||
;; ---- Actions ----
|
||||
;; action: RequestVote
|
||||
(func $RequestVote (export "RequestVote") (param $candidate i32) (param $lastLogTerm i32) (param $lastLogIndex i32) (result i32)
|
||||
|
||||
;; require: state == Candidate
|
||||
;; Complex right-hand side not compiled: state == Candidate
|
||||
;; require: currentTerm >= lastLogTerm
|
||||
;; Complex right-hand side not compiled: currentTerm >= lastLogTerm
|
||||
;; votesGranted := votesGranted union {candidate}
|
||||
;; Unrecognized assignment target: votesGranted
|
||||
(i32.const 1)
|
||||
)
|
||||
|
||||
;; action: HandleRequestVoteRequest
|
||||
(func $HandleRequestVoteRequest (export "HandleRequestVoteRequest") (param $candidate i32) (param $candidateTerm i32) (param $lastLogTerm i32) (param $lastLogIndex i32) (result i32)
|
||||
|
||||
;; require: currentTerm >= candidateTerm
|
||||
;; Complex right-hand side not compiled: currentTerm >= candidateTerm
|
||||
;; votedFor := Some(candidate)
|
||||
(i32.const 0) ;; uncompiled expr: Some(candidate)
|
||||
(global.set $votedFor)
|
||||
(i32.const 1)
|
||||
)
|
||||
|
||||
;; action: BecomeLeader
|
||||
(func $BecomeLeader (export "BecomeLeader") (result i32)
|
||||
|
||||
;; require: state == Candidate
|
||||
;; Complex right-hand side not compiled: state == Candidate
|
||||
;; require: votesGranted.size() >= 3
|
||||
;; Complex guard not compiled: votesGranted.size() >= 3
|
||||
;; state := Leader
|
||||
(i32.const 0) ;; uncompiled expr: Leader
|
||||
(global.set $state)
|
||||
;; commitIndex := length(log)
|
||||
(i32.const 0) ;; uncompiled expr: length(log)
|
||||
(global.set $commitIndex)
|
||||
(i32.const 1)
|
||||
)
|
||||
|
||||
;; action: AppendEntries
|
||||
(func $AppendEntries (export "AppendEntries") (param $follower i32) (result i32)
|
||||
|
||||
;; require: state == Leader
|
||||
;; Complex right-hand side not compiled: state == Leader
|
||||
;; log := append(log, newEntry)
|
||||
;; Unrecognized assignment target: log
|
||||
(i32.const 1)
|
||||
)
|
||||
|
||||
;; ---- getState ----
|
||||
(func $getState (export "getState") (result i32 i32)
|
||||
(i32.const 0)
|
||||
(global.get $currentTerm)
|
||||
(i32.store)
|
||||
(i32.const 4)
|
||||
(global.get $state)
|
||||
(i32.store)
|
||||
(i32.const 8)
|
||||
(global.get $votedFor)
|
||||
(i32.store)
|
||||
(i32.const 12)
|
||||
(global.get $commitIndex)
|
||||
(i32.store)
|
||||
(i32.const 0)
|
||||
(i32.const 16)
|
||||
)
|
||||
|
||||
|
||||
;; checkConstraints — runtime constraint evaluation stub
|
||||
(func $checkConstraints (export "checkConstraints") (result i32)
|
||||
;; TODO: evaluate constraint expressions at runtime
|
||||
(i32.const 1)
|
||||
)
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue