mirror of
https://github.com/hanzomsg/nats-server.git
synced 2026-10-30 20:27:34 +00:00
376 lines
10 KiB
Go
376 lines
10 KiB
Go
// Copyright 2024-2025 The NATS Authors
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
// Raft Chain of Blocks (RCOB) is a replicated state machine on top of Raft used to test the latter.
|
|
// Each value ("block") proposed to raft is a simple array of bytes (stateless, can be small or large).
|
|
// Each replica state consists of a hash of all blocks delivered so far.
|
|
// This makes it easy to check consistency over time.
|
|
// If all replicas deliver the same blocks in the same order (the whole point of Raft!), then the N-th hash on each
|
|
// replica should be identical. This invariant is easy to verify in test settings, because if at any point a replica
|
|
// delivered (hashed) a different value, then all subsequent hashes will diverge from the rest.
|
|
|
|
package server
|
|
|
|
import (
|
|
"encoding"
|
|
"encoding/json"
|
|
"fmt"
|
|
"hash"
|
|
"hash/crc32"
|
|
"math/rand"
|
|
"sync"
|
|
)
|
|
|
|
// Static options (but may be useful to tweak when debugging)
|
|
var RCOBOptions = struct {
|
|
verbose bool
|
|
// Proposed block [data] size is random between 1 and maxBlockSize
|
|
maxBlockSize int
|
|
// If set to true, the state machine will not take snapshots while in recovery
|
|
// (corresponding to the 'ready' state machine member variable)
|
|
safeSnapshots bool
|
|
}{
|
|
false,
|
|
10240,
|
|
true,
|
|
}
|
|
|
|
// Simple state machine on top of Raft.
|
|
// Each value delivered is hashed, N-th value hash should match on all replicas or something went wrong.
|
|
// If even a single value in the chain of hash differs on one replica, hashes will diverge.
|
|
type RCOBStateMachine struct {
|
|
sync.Mutex
|
|
s *Server
|
|
n RaftNode
|
|
cfg *RaftConfig
|
|
wg sync.WaitGroup
|
|
leader bool
|
|
proposalSequence uint64
|
|
rng *rand.Rand
|
|
hash hash.Hash
|
|
blocksApplied uint64
|
|
blocksAppliedSinceSnapshot uint64
|
|
stopped bool
|
|
ready bool
|
|
}
|
|
|
|
func (sm *RCOBStateMachine) waitGroup() *sync.WaitGroup {
|
|
sm.Lock()
|
|
defer sm.Unlock()
|
|
return &sm.wg
|
|
}
|
|
|
|
// RCOBBlock is the structure of values replicated through Raft.
|
|
// Block data is a random array of bytes.
|
|
// Additional proposer metadata is present for debugging and for further ordering invariant checks (but not included
|
|
// in the hash).
|
|
type RCOBBlock struct {
|
|
Proposer string
|
|
ProposerSequence uint64
|
|
Data []byte
|
|
}
|
|
|
|
// logDebug is for fine-grained event logging, useful for debugging but default off
|
|
func (sm *RCOBStateMachine) logDebug(format string, args ...any) {
|
|
if RCOBOptions.verbose {
|
|
fmt.Printf("["+sm.s.Name()+" ("+sm.n.ID()+")] "+format+"\n", args...)
|
|
}
|
|
}
|
|
|
|
func (sm *RCOBStateMachine) server() *Server {
|
|
sm.Lock()
|
|
defer sm.Unlock()
|
|
return sm.s
|
|
}
|
|
|
|
func (sm *RCOBStateMachine) node() RaftNode {
|
|
sm.Lock()
|
|
defer sm.Unlock()
|
|
return sm.n
|
|
}
|
|
|
|
func (sm *RCOBStateMachine) propose(data []byte) {
|
|
sm.Lock()
|
|
defer sm.Unlock()
|
|
if !sm.ready {
|
|
sm.logDebug("Refusing to propose during recovery")
|
|
}
|
|
err := sm.n.ForwardProposal(data)
|
|
if err != nil {
|
|
sm.logDebug("block proposal error: %s", err)
|
|
}
|
|
}
|
|
|
|
func (sm *RCOBStateMachine) applyEntry(ce *CommittedEntry) {
|
|
sm.Lock()
|
|
defer sm.Unlock()
|
|
if ce == nil {
|
|
// A nil entry signals that the previous recovery backlog is over
|
|
sm.logDebug("Recovery complete")
|
|
sm.ready = true
|
|
return
|
|
}
|
|
sm.logDebug("Apply entries #%d (%d entries)", ce.Index, len(ce.Entries))
|
|
for _, entry := range ce.Entries {
|
|
if entry.Type == EntryNormal {
|
|
sm.applyBlock(entry.Data)
|
|
} else if entry.Type == EntrySnapshot {
|
|
sm.loadSnapshot(entry.Data)
|
|
} else if entry.Type == EntryCatchup {
|
|
// Ignore.
|
|
} else {
|
|
panic(fmt.Sprintf("[%s] unknown entry type: %s", sm.s.Name(), entry.Type))
|
|
}
|
|
}
|
|
// Signal to the node that entries were applied
|
|
sm.n.Applied(ce.Index)
|
|
}
|
|
|
|
func (sm *RCOBStateMachine) leaderChange(isLeader bool) {
|
|
sm.Lock()
|
|
defer sm.Unlock()
|
|
if sm.leader && !isLeader {
|
|
sm.logDebug("Leader change: no longer leader")
|
|
} else if sm.leader && isLeader {
|
|
sm.logDebug("Elected leader while already leader")
|
|
} else if !sm.leader && isLeader {
|
|
sm.logDebug("Leader change: i am leader")
|
|
} else {
|
|
sm.logDebug("Leader change")
|
|
}
|
|
sm.leader = isLeader
|
|
if isLeader != sm.n.Leader() {
|
|
sm.logDebug("⚠️ Leader state out of sync with underlying node")
|
|
}
|
|
}
|
|
|
|
func (sm *RCOBStateMachine) stop() {
|
|
n, wg := sm.node(), sm.waitGroup()
|
|
n.Stop()
|
|
n.WaitForStop()
|
|
wg.Wait()
|
|
|
|
// Clear state, on restart it will be recovered from snapshot or peers
|
|
sm.Lock()
|
|
defer sm.Unlock()
|
|
|
|
sm.stopped = true
|
|
sm.blocksApplied = 0
|
|
sm.hash.Reset()
|
|
sm.leader = false
|
|
sm.logDebug("Stopped")
|
|
}
|
|
|
|
func (sm *RCOBStateMachine) restart() {
|
|
sm.Lock()
|
|
defer sm.Unlock()
|
|
|
|
sm.logDebug("Restarting")
|
|
|
|
sm.stopped = false
|
|
sm.ready = false
|
|
if sm.n.State() != Closed {
|
|
return
|
|
}
|
|
|
|
// The filestore is stopped as well, so need to extract the parts to recreate it.
|
|
rn := sm.n.(*raft)
|
|
fs := rn.wal.(*fileStore)
|
|
|
|
var err error
|
|
sm.cfg.Log, err = newFileStore(fs.fcfg, fs.cfg.StreamConfig)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
sm.n, err = sm.s.startRaftNode(globalAccountName, sm.cfg, pprofLabels{})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
// Finally restart the driver.
|
|
go smLoop(sm)
|
|
}
|
|
|
|
func (sm *RCOBStateMachine) proposeBlock() {
|
|
// Keep track how many blocks this replica proposed
|
|
sm.proposalSequence += 1
|
|
|
|
// Create a block
|
|
blockSize := 1 + sm.rng.Intn(RCOBOptions.maxBlockSize)
|
|
block := RCOBBlock{
|
|
Proposer: sm.s.Name(),
|
|
ProposerSequence: sm.proposalSequence,
|
|
Data: make([]byte, blockSize),
|
|
}
|
|
|
|
// Data is random bytes
|
|
sm.rng.Read(block.Data)
|
|
|
|
// Serialize block to JSON
|
|
blockData, err := json.Marshal(block)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("serialization error: %s", err))
|
|
}
|
|
|
|
sm.logDebug(
|
|
"Proposing block <%s, %d, [%dB]>",
|
|
block.Proposer,
|
|
block.ProposerSequence,
|
|
len(block.Data),
|
|
)
|
|
|
|
// Propose block (may fail, and that's ok)
|
|
sm.propose(blockData)
|
|
}
|
|
|
|
func (sm *RCOBStateMachine) applyBlock(data []byte) {
|
|
// Deserialize block
|
|
var block RCOBBlock
|
|
err := json.Unmarshal(data, &block)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("deserialization error: %s", err))
|
|
}
|
|
|
|
sm.logDebug("Applying block <%s, %d>", block.Proposer, block.ProposerSequence)
|
|
|
|
// Hash the data on top of the existing running hash
|
|
n, err := sm.hash.Write(block.Data)
|
|
if n != len(block.Data) {
|
|
panic(fmt.Sprintf("unexpected checksum written %d data block size: %d", n, len(block.Data)))
|
|
} else if err != nil {
|
|
panic(fmt.Sprintf("checksum error: %s", err))
|
|
}
|
|
|
|
// Track numbers of blocks applied
|
|
sm.blocksApplied += 1
|
|
sm.blocksAppliedSinceSnapshot += 1
|
|
|
|
sm.logDebug("Hash after %d blocks: %X ", sm.blocksApplied, sm.hash.Sum(nil))
|
|
}
|
|
|
|
func (sm *RCOBStateMachine) getCurrentHash() (bool, uint64, string) {
|
|
sm.Lock()
|
|
defer sm.Unlock()
|
|
|
|
// Return running, the number of blocks applied and the current running hash
|
|
return !sm.stopped, sm.blocksApplied, fmt.Sprintf("%X", sm.hash.Sum(nil))
|
|
}
|
|
|
|
// RCOBSnapshot structure for RCOB snapshots
|
|
// Consists of the hash (32b) after BlocksCount blocks were hashed.
|
|
// TODO: Could start storing last N blocks to make snapshot larger and exercise more code paths.
|
|
// (a big chunk of random data would also do the trick)
|
|
type RCOBSnapshot struct {
|
|
SourceNode string
|
|
HashData []byte
|
|
BlocksCount uint64
|
|
}
|
|
|
|
// createSnapshot asks the state machine to create a snapshot.
|
|
// The request may be ignored if no blocks were applied since the last snapshot.
|
|
func (sm *RCOBStateMachine) createSnapshot() {
|
|
sm.Lock()
|
|
defer sm.Unlock()
|
|
|
|
if sm.blocksAppliedSinceSnapshot == 0 {
|
|
sm.logDebug("Skip snapshot, no new entries")
|
|
return
|
|
}
|
|
|
|
if RCOBOptions.safeSnapshots && !sm.ready {
|
|
sm.logDebug("Skip snapshot, still recovering")
|
|
return
|
|
}
|
|
|
|
sm.logDebug(
|
|
"Snapshot (with %d blocks applied, %d since last snapshot)",
|
|
sm.blocksApplied,
|
|
sm.blocksAppliedSinceSnapshot,
|
|
)
|
|
|
|
// Serialize the internal state of the hash block
|
|
serializedHash, err := sm.hash.(encoding.BinaryMarshaler).MarshalBinary()
|
|
if err != nil {
|
|
panic(fmt.Sprintf("failed to marshal hash: %s", err))
|
|
}
|
|
|
|
// Create snapshot
|
|
snapshot := RCOBSnapshot{
|
|
SourceNode: fmt.Sprintf("%s (%s)", sm.s.Name(), sm.n.ID()),
|
|
HashData: serializedHash,
|
|
BlocksCount: sm.blocksApplied,
|
|
}
|
|
|
|
// Serialize snapshot as JSON
|
|
snapshotData, err := json.Marshal(snapshot)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("failed to marshal snapshot: %s", err))
|
|
}
|
|
|
|
// InstallSnapshot is actually "save the snapshot", which is an operation delegated to the node
|
|
err = sm.n.InstallSnapshot(snapshotData, false)
|
|
if err != nil {
|
|
sm.logDebug("failed to snapshot: %s", err)
|
|
return
|
|
}
|
|
|
|
// Reset counter since last snapshot
|
|
sm.blocksAppliedSinceSnapshot = 0
|
|
}
|
|
|
|
func (sm *RCOBStateMachine) loadSnapshot(data []byte) {
|
|
// Deserialize snapshot from JSON
|
|
var snapshot RCOBSnapshot
|
|
err := json.Unmarshal(data, &snapshot)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("failed to unmarshal snapshot: %s", err))
|
|
}
|
|
|
|
sm.logDebug(
|
|
"Applying snapshot (created by %s) taken after %d blocks",
|
|
snapshot.SourceNode,
|
|
snapshot.BlocksCount,
|
|
)
|
|
|
|
// Load internal hash state
|
|
err = sm.hash.(encoding.BinaryUnmarshaler).UnmarshalBinary(snapshot.HashData)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("failed to unmarshal hash data: %s", err))
|
|
}
|
|
|
|
// Load block counter
|
|
sm.blocksApplied = snapshot.BlocksCount
|
|
|
|
// Reset number of blocks applied since snapshot
|
|
sm.blocksAppliedSinceSnapshot = 0
|
|
|
|
sm.logDebug("Hash after snapshot with %d blocks: %X ", sm.blocksApplied, sm.hash.Sum(nil))
|
|
}
|
|
|
|
// Factory function to create state machine on top of the given server/node
|
|
func newRaftChainStateMachine(s *Server, cfg *RaftConfig, n RaftNode) stateMachine {
|
|
// Create RNG seed based on server name and node id
|
|
var seed int64
|
|
for _, c := range []byte(s.Name()) {
|
|
seed += int64(c)
|
|
}
|
|
for _, c := range []byte(n.ID()) {
|
|
seed += int64(c)
|
|
}
|
|
rng := rand.New(rand.NewSource(seed))
|
|
|
|
// Initialize empty hash block
|
|
hashBlock := crc32.NewIEEE()
|
|
|
|
return &RCOBStateMachine{s: s, n: n, cfg: cfg, rng: rng, hash: hashBlock}
|
|
}
|