Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,6 @@ go

# terraform
.terraform*

# raft
raft_data/
6 changes: 4 additions & 2 deletions pkg/aux_/store/raftstore/store.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package raftstore

import (
"context"

"github.com/interuss/dss/pkg/aux_/repos"
"github.com/interuss/dss/pkg/raftstore"
"go.uber.org/zap"
Expand All @@ -9,6 +11,6 @@ import (
// repo is a full implementation of aux_.repos.Repository for Raft-based storage.
type repo struct{}

func Init(logger *zap.Logger) (*raftstore.Store[repos.Repository], error) {
return raftstore.Init[repos.Repository](logger, func() repos.Repository { return &repo{} })
func Init(ctx context.Context, logger *zap.Logger) (*raftstore.Store[repos.Repository], error) {
return raftstore.Init[repos.Repository](ctx, logger, func() repos.Repository { return &repo{} })
}
2 changes: 1 addition & 1 deletion pkg/aux_/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func Init(ctx context.Context, logger *zap.Logger, withCheckCron bool) (Store, e
case params.SQLStoreType:
return auxsqlstore.Init(ctx, logger, withCheckCron)
case params.RaftStoreType:
return auxraftstore.Init(logger)
return auxraftstore.Init(ctx, logger)
default:
return nil, stacktrace.NewError("Unsupported store type %q for aux", storeType)
}
Expand Down
37 changes: 27 additions & 10 deletions pkg/raftstore/consensus/consensus.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"net/url"

"github.com/interuss/dss/pkg/logging"
"github.com/interuss/stacktrace"
"go.etcd.io/etcd/client/pkg/v3/types"
"go.etcd.io/etcd/server/v3/etcdserver/api/rafthttp"
Expand All @@ -16,11 +17,14 @@ import (
"go.uber.org/zap"
)

const defaultClusterID uint64 = 1
const (
defaultClusterID uint64 = 1
)

type Consensus struct {
node raft.Node
raftStorage *raft.MemoryStorage
logger *zap.Logger

node raft.Node

transport *rafthttp.Transport
server *http.Server
Expand All @@ -29,29 +33,37 @@ type Consensus struct {
errorC chan error
}

func NewConsensus(logger *zap.Logger, nodeID uint64, peers map[uint64]*url.URL) (*Consensus, error) {
func NewConsensus(ctx context.Context, logger *zap.Logger, nodeID uint64, peers map[uint64]*url.URL, dataDir string, snapshotCatchupEntries uint64) (*Consensus, error) {
storage, _, err := newStorage(ctx, logger.With(zap.String("component", "storage")), dataDir, nodeID, snapshotCatchupEntries)
if err != nil {
return nil, stacktrace.Propagate(err, "failed to initialize storage")
}

consensus := &Consensus{
errorC: make(chan error, 1),
logger: logging.WithValuesFromContext(ctx, logger),

storage: storage,
errorC: make(chan error, 1),
}

err := consensus.initTransport(logger.With(zap.String("component", "transport")), nodeID, defaultClusterID, peers)
err = consensus.initTransport(ctx, nodeID, defaultClusterID, peers)
if err != nil {
return nil, stacktrace.Propagate(err, "failed to initialize transport")
}

return consensus, nil
}

func (c *Consensus) initTransport(logger *zap.Logger, nodeID uint64, clusterID uint64, peers map[uint64]*url.URL) error {
func (c *Consensus) initTransport(ctx context.Context, nodeID uint64, clusterID uint64, peers map[uint64]*url.URL) error {
nodeIDStr := fmt.Sprintf("%d", nodeID)

transport := &rafthttp.Transport{
Logger: logger,
Logger: logging.WithValuesFromContext(ctx, c.logger.With(zap.String("component", "transport"))),
ID: types.ID(nodeID),
ClusterID: types.ID(clusterID),
Raft: c,
ServerStats: v2stats.NewServerStats(nodeIDStr, nodeIDStr),
LeaderStats: v2stats.NewLeaderStats(logger, nodeIDStr),
LeaderStats: v2stats.NewLeaderStats(c.logger, nodeIDStr),
ErrorC: c.errorC,
}

Expand Down Expand Up @@ -82,7 +94,7 @@ func (c *Consensus) initTransport(logger *zap.Logger, nodeID uint64, clusterID u
go func() {
err := c.server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("http server error", zap.Error(err))
c.logger.Error("http server error", zap.Error(err))
c.errorC <- err
}
}()
Expand Down Expand Up @@ -110,3 +122,8 @@ func (c *Consensus) ReportUnreachable(id uint64) {
func (c *Consensus) ReportSnapshot(id uint64, status raft.SnapshotStatus) {
c.node.ReportSnapshot(id, status)
}

// RegisterStore allows registering a snapshot provider function for a specific store
func (c *Consensus) RegisterStore(name string, provider snapshotProvider) {
c.storage.registerSnapshotProvider(name, provider)
}
262 changes: 259 additions & 3 deletions pkg/raftstore/consensus/storage.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,268 @@
package consensus

import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path"

"github.com/interuss/dss/pkg/logging"
"github.com/interuss/stacktrace"
"go.etcd.io/etcd/client/pkg/v3/fileutil"
"go.etcd.io/etcd/server/v3/etcdserver/api/snap"
"go.etcd.io/etcd/server/v3/storage/wal"
"go.etcd.io/etcd/server/v3/storage/wal/walpb"
"go.etcd.io/raft/v3"
"go.etcd.io/raft/v3/raftpb"
"go.uber.org/zap"
)

// storage persists the Raft log and snapshots.
// snapshotProvider is a function that returns the snapshot data to be included in the Raft snapshot.
// We use a snapshotProvider from each component (scd, rid and aux).
type snapshotProvider func() ([]byte, error)

// storage persists the Raft log and snapshots and manages the raft in-memory storage.
type storage struct {
wal *wal.WAL
snapshotter *snap.Snapshotter
logger *zap.Logger

*raft.MemoryStorage

wal *wal.WAL

snapper *snap.Snapshotter
providers map[string]snapshotProvider

snapshotCatchUpEntries uint64
}

// newStorage initializes the storage by loading the latest snapshot and wal entries from the disk
// and applies them to the Raft memory storage.
// It returns the initialized storage, a boolean indicating whether the storage was pre-existent or an error.
func newStorage(ctx context.Context, logger *zap.Logger, dataDir string, nodeID uint64, snapshotCatchUpEntries uint64) (*storage, bool, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And an additional clarification: is this storage OK in a cluster environment where there are multiple DSS pods? Is this the intent of the nodeID or not at all?

@MariemBaccari MariemBaccari Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that is the intent of nodeID :) I can run multiple nodes locally safely.

logger = logging.WithValuesFromContext(ctx, logger)

// load the latest snapshot
snapshotPath := path.Join(dataDir, fmt.Sprintf("snapshot_%d", nodeID))
if !fileutil.Exist(snapshotPath) {
err := os.MkdirAll(snapshotPath, 0o750)
if err != nil {
return nil, false, stacktrace.Propagate(err, "failed to create directory for snapshot storage at: %s", snapshotPath)
}
}

walPath := path.Join(dataDir, fmt.Sprintf("wal_%d", nodeID))

snapper := snap.New(logger, snapshotPath)

var (
w *wal.WAL
err error
snapshot *raftpb.Snapshot
)
ok := wal.Exist(walPath)
if !ok {
if err = os.MkdirAll(walPath, 0o750); err != nil {
return nil, false, stacktrace.Propagate(err, "failed to create directory for wal storage at: %s", walPath)
}
w, err = wal.Create(logger, walPath, nil)
if err != nil {
return nil, false, stacktrace.Propagate(err, "failed to create wal at: %s", walPath)
}
if err = w.Close(); err != nil {
return nil, false, stacktrace.Propagate(err, "failed to close wal at: %s", walPath)
}
snapshot = &raftpb.Snapshot{}
} else {
snapshot, err = loadSnapshot(logger, walPath, snapper)
if err != nil {
return nil, false, stacktrace.Propagate(err, "failed to load snapshot")
}
}

// open the wal at the given snapshot and get all subsequent entries
w, err = wal.Open(logger, walPath, walpb.Snapshot{
Index: snapshot.Metadata.Index,
Term: snapshot.Metadata.Term,
ConfState: &snapshot.Metadata.ConfState,
})
if err != nil {
return nil, false, stacktrace.Propagate(err, "failed to open wal with index %d, term %d and confstate %v at: %s", snapshot.Metadata.Index, snapshot.Metadata.Term, snapshot.Metadata.ConfState, walPath)
}

_, state, entries, err := w.ReadAll()
if err != nil {
return nil, false, stacktrace.Propagate(err, "failed to read wal state and entries")
}

// initialize the raft memory storage with the loaded snapshot and wal entries
raftMemoryStorage := raft.NewMemoryStorage()
if !raft.IsEmptySnap(*snapshot) {
err = raftMemoryStorage.ApplySnapshot(*snapshot)
if err != nil {
return nil, false, stacktrace.Propagate(err, "failed to apply snapshot to raft memory storage")
}
}

err = raftMemoryStorage.SetHardState(state)
if err != nil {
return nil, false, stacktrace.Propagate(err, "failed to set hard state to raft memory storage")
}

err = raftMemoryStorage.Append(entries)
if err != nil {
return nil, false, stacktrace.Propagate(err, "failed to append entries to raft memory storage")
}

logger.Info("Loaded previous hardstate and entries to Raft memory storage", zap.Any("hard-state", state), zap.Int("entries-number", len(entries)))

return &storage{
logger: logger,

MemoryStorage: raftMemoryStorage,

wal: w,

snapper: snapper,
providers: make(map[string]snapshotProvider),
snapshotCatchUpEntries: snapshotCatchUpEntries,
}, ok, nil
}

func loadSnapshot(logger *zap.Logger, walPath string, snapshotter *snap.Snapshotter) (*raftpb.Snapshot, error) {
entries, err := wal.ValidSnapshotEntries(logger, walPath)
if err != nil {
return nil, stacktrace.Propagate(err, "failed to get valid snapshot entries")
}

snapshot, err := snapshotter.LoadNewestAvailable(entries)
if err != nil {
if errors.Is(err, snap.ErrNoSnapshot) {
return &raftpb.Snapshot{}, nil
}

return nil, stacktrace.Propagate(err, "failed to load newest available snapshot")
}

logger.Info("Loaded snapshot", zap.Uint64("index", snapshot.Metadata.Index), zap.Uint64("term", snapshot.Metadata.Term))
return snapshot, nil
}

// save saves the given snapshot to the snapshotter and the wal.
func (s *storage) save(snapshot raftpb.Snapshot) error {
err := s.snapper.SaveSnap(snapshot)
if err != nil {
return stacktrace.Propagate(err, "failed to save snapshot")
}

err = s.wal.SaveSnapshot(walpb.Snapshot{
Index: snapshot.Metadata.Index,
Term: snapshot.Metadata.Term,
ConfState: &snapshot.Metadata.ConfState,
})
if err != nil {
return stacktrace.Propagate(err, "failed to save snapshot to wal")
}

// ReleaseLockTo releases the os-level flock locks held on WAL segment files
// for entries covered by the snapshot and that are no longer needed.
// We only call this on success. If saving the snapshot failed above, the files
// are still needed and must remain locked.
//
// In etcd's implementation this call marks files eligible for deletion
// by a background cleanup goroutine which we do not have yet.
//
// TODO: add a separate goroutine to cleanup old files
return s.wal.ReleaseLockTo(snapshot.Metadata.Index)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a comment on where the lock was acquired

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1
And does the lock need to be released when an error is returned?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are os level flock locks on WAL segment files that are acquired internally in the WAL implementation. ReleaseLockTo is used to release the locks for the entries that are already covered by the snapshot and, thus, the files are not needed anymore.
We shouldn't release the locks when an error is returned since the files are still needed if we fail to save the snapshot.
I realized, while looking into etcd's source code, that this call is actually useless in the current state of our implementation. Etcd uses this as a way to mark files that can be cleaned up (separately). I will keep it for now and add a comment to the function and the github issue to implement the cleanup later.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also include this rationale in the comment in code :)

}

func (s *storage) registerSnapshotProvider(name string, provider func() ([]byte, error)) {
s.providers[name] = provider
}

// getSnapshot calls all registered snapshot providers and combines their data into a single snapshot.
func (s *storage) getSnapshot() ([]byte, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this fits json.Marshaler interface: https://pkg.go.dev/encoding/json#Marshaler

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true but I think we should keep this method signature as it clearly indicates the specific purpose of getting a snapshot of the storage. The underlying implementation can always change and just happens to be a json marshalling for the moment.

parts := make(map[string][]byte)
for name, provider := range s.providers {
data, err := provider()
if err != nil {
return nil, stacktrace.Propagate(err, "failed to get snapshot data from %q", name)
}

parts[name] = data
}

return json.Marshal(parts)
}

// snapshotter returns the snapshotter used by the storage.
func (s *storage) snapshotter() *snap.Snapshotter {
return s.snapper
}

func (s *storage) triggerSnapshot(appliedIndex uint64, confState *raftpb.ConfState) error {
s.logger.Info("triggering snapshot", zap.Uint64("appliedIndex", appliedIndex))
data, err := s.getSnapshot()
if err != nil {
return stacktrace.Propagate(err, "failed to get snapshot data")
}

snap, err := s.CreateSnapshot(appliedIndex, confState, data)
if err != nil {
return stacktrace.Propagate(err, "failed to create snapshot from raft memory storage")
}

err = s.save(snap)
if err != nil {
return stacktrace.Propagate(err, "failed to save snapshot")
}

compactIndex := uint64(1)
if appliedIndex > s.snapshotCatchUpEntries {
compactIndex = appliedIndex - s.snapshotCatchUpEntries
}

return s.compactLog(compactIndex)
}

func (s *storage) compactLog(compactIndex uint64) error {
err := s.Compact(compactIndex)
if errors.Is(err, raft.ErrCompacted) {
s.logger.Warn("log already compacted", zap.Uint64("compactIndex", compactIndex))
return nil
}
if err != nil {
return stacktrace.Propagate(err, "failed to compact raft memory storage at index %d", compactIndex)
}

s.logger.Info("compacted log", zap.Uint64("compactIndex", compactIndex))
return nil
}

func (s *storage) handleReceivedState(snapshot raftpb.Snapshot, hardState raftpb.HardState, entries []raftpb.Entry) error {
if !raft.IsEmptySnap(snapshot) {
err := s.save(snapshot)
if err != nil {
return stacktrace.Propagate(err, "failed to save snapshot")
}
}

err := s.wal.Save(hardState, entries)
if err != nil {
return stacktrace.Propagate(err, "failed to save WAL entries")
}

if !raft.IsEmptySnap(snapshot) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Control flow: is it actually necessary to check for this condition twice?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we cannot change the ordering of saving the snapshot -> saving the entries -> applying, I think we have to check for this condition twice.

err := s.ApplySnapshot(snapshot)
if err != nil {
return stacktrace.Propagate(err, "failed to apply snapshot")
}
}

if err := s.Append(entries); err != nil {
return stacktrace.Propagate(err, "failed to append entries to raft storage")
}

return nil
}
Loading
Loading