Files
worker/internal/workercluster/bootstrap.go
Gleb Tv 2c7a0236da feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
2026-07-13 17:55:14 +03:00

120 строки
3.0 KiB
Go

package workercluster
import (
"context"
"fmt"
"sync"
"time"
)
// BootstrapResult is the outcome of a Bootstrap call.
type BootstrapResult struct {
// Leader is the node id that ended up holding leadership when
// bootstrap finished. For a 1-node bootstrap this is always the
// local node.
Leader string
// Voters is the final voter set as observed on the local node.
Voters []string
}
// Bootstrap starts a fresh cluster from the first node. It is a
// convenience wrapper around New + Start + BootstrapCluster for the
// common single-node bootstrap case. Production code that needs
// custom timeouts should call New / Start directly.
func Bootstrap(ctx context.Context, opts *Options) (*Cluster, BootstrapResult, error) {
opts.Bootstrap = true
opts.Seed = Peer{}
c, err := New(opts)
if err != nil {
return nil, BootstrapResult{}, err
}
if err := c.Start(ctx); err != nil {
return nil, BootstrapResult{}, err
}
if err := waitForLeader(ctx, c, 10*time.Second); err != nil {
_ = c.Shutdown(ctx)
return nil, BootstrapResult{}, err
}
stats := c.Stats()
return c, BootstrapResult{Leader: stats.Leader, Voters: votersFromStats(&stats)}, nil
}
// JoinCluster brings up a new node that joins an existing cluster via
// the given seed peer. It blocks until the local node is a voter in
// the raft configuration.
func JoinCluster(ctx context.Context, opts *Options, seed Peer) (*Cluster, error) {
opts.Bootstrap = false
opts.Seed = seed
c, err := New(opts)
if err != nil {
return nil, err
}
if err := c.Start(ctx); err != nil {
return nil, err
}
if err := waitForLeader(ctx, c, 15*time.Second); err != nil {
_ = c.Shutdown(ctx)
return nil, err
}
return c, nil
}
// waitForLeader blocks until the cluster has a leader (or ctx is
// canceled, or timeout elapses).
func waitForLeader(ctx context.Context, c *Cluster, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for {
stats := c.Stats()
if stats.Leader != "" {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("workercluster: no leader after %s (state=%s)", timeout, stats.State)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(50 * time.Millisecond):
}
}
}
// votersFromStats returns the voter WorkerIDs from the cluster stats.
// It is best-effort: the FSM membership cache is the source of truth.
func votersFromStats(s *Stats) []string {
out := make([]string, 0, len(s.Members))
for _, m := range s.Members {
if m.Role == RoleVoter {
out = append(out, m.WorkerID)
}
}
return out
}
// Parallel starts the given cluster starts concurrently and returns
// once all of them have completed (or the first one errors).
func Parallel(starts ...func() error) error {
var wg sync.WaitGroup
errs := make(chan error, len(starts))
for _, fn := range starts {
wg.Add(1)
go func(fn func() error) {
defer wg.Done()
if err := fn(); err != nil {
errs <- err
}
}(fn)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
return err
}
}
return nil
}