Update internal/cluster/raft_coordinator.go
This commit is contained in:
1 parent
384553da25
commit
0d7bad66bb
1 file changed
+243
-75
@@ -16,14 +16,17 @@ package cluster
|
|||||||
import (
|
import (
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hashicorp/raft"
|
"github.com/hashicorp/raft"
|
||||||
@@ -33,6 +36,45 @@ import (
|
|||||||
"futriis/internal/storage"
|
"futriis/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// КРОССПЛАТФОРМЕННЫЕ УТИЛИТЫ
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// isTemporaryNetError проверяет, является ли ошибка временной (для Linux и illumos).
|
||||||
|
// На illumos (OpenIndiana) accept может возвращать EINTR, EAGAIN, EWOULDBLOCK.
|
||||||
|
func isTemporaryNetError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Стандартная проверка через net.Error
|
||||||
|
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Проверка syscall-ошибок для illumos/Linux
|
||||||
|
if errors.Is(err, syscall.EINTR) ||
|
||||||
|
errors.Is(err, syscall.EAGAIN) ||
|
||||||
|
errors.Is(err, syscall.EWOULDBLOCK) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isClockSkewAcceptable проверяет, что рассинхронизация часов в пределах допустимого.
|
||||||
|
// Используется для защиты от last-write-wins на основе рассинхронизированных часов.
|
||||||
|
func isClockSkewAcceptable(remoteTS, localTS int64, maxSkewMs int64) bool {
|
||||||
|
diff := remoteTS - localTS
|
||||||
|
if diff < 0 {
|
||||||
|
diff = -diff
|
||||||
|
}
|
||||||
|
return diff <= maxSkewMs
|
||||||
|
}
|
||||||
|
|
||||||
|
// monotonicTimestamp возвращает монотонную метку времени, устойчивую к переводам часов.
|
||||||
|
// На illumos time.Now() использует CLOCK_MONOTONIC для монотонной части.
|
||||||
|
func monotonicTimestamp() int64 {
|
||||||
|
return time.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// ИНТЕРФЕЙСЫ И БАЗОВЫЕ ТИПЫ (используются из node.go)
|
// ИНТЕРФЕЙСЫ И БАЗОВЫЕ ТИПЫ (используются из node.go)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -40,6 +82,49 @@ import (
|
|||||||
// LoggerInterface, NodeStatus, StatusOffline, StatusActive, StatusSyncing, StatusFailed,
|
// LoggerInterface, NodeStatus, StatusOffline, StatusActive, StatusSyncing, StatusFailed,
|
||||||
// Node, NodeInfo - определены в node.go
|
// Node, NodeInfo - определены в node.go
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ЛОГИЧЕСКИЕ ЧАСЫ (LAMPORT CLOCK) ДЛЯ EVENTUAL CONSISTENCY
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// LamportClock реализует логические часы Лампорта для упорядочивания событий
|
||||||
|
// без зависимости от физических часов узлов.
|
||||||
|
type LamportClock struct {
|
||||||
|
counter atomic.Uint64
|
||||||
|
nodeID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLamportClock создаёт новые логические часы.
|
||||||
|
func NewLamportClock(nodeID string) *LamportClock {
|
||||||
|
return &LamportClock{nodeID: nodeID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick увеличивает счётчик при локальном событии.
|
||||||
|
func (lc *LamportClock) Tick() uint64 {
|
||||||
|
return lc.counter.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe обновляет счётчик при получении удалённого события.
|
||||||
|
// Правило Лампорта: counter = max(counter, remote) + 1
|
||||||
|
func (lc *LamportClock) Observe(remote uint64) uint64 {
|
||||||
|
for {
|
||||||
|
current := lc.counter.Load()
|
||||||
|
var next uint64
|
||||||
|
if remote > current {
|
||||||
|
next = remote + 1
|
||||||
|
} else {
|
||||||
|
next = current + 1
|
||||||
|
}
|
||||||
|
if lc.counter.CompareAndSwap(current, next) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value возвращает текущее значение.
|
||||||
|
func (lc *LamportClock) Value() uint64 {
|
||||||
|
return lc.counter.Load()
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// ДИАПАЗОННЫЕ ШАРДЫ (RANGE SHARDS)
|
// ДИАПАЗОННЫЕ ШАРДЫ (RANGE SHARDS)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -545,10 +630,13 @@ func (rsm *RangeShardManager) Rebalance() error {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// InmemStore реализует хранилище для Raft в памяти
|
// InmemStore реализует хранилище для Raft в памяти
|
||||||
|
// Корректная обработка конфликтов логов при смене лидера.
|
||||||
type InmemStore struct {
|
type InmemStore struct {
|
||||||
data map[string][]byte
|
data map[string][]byte
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
path string
|
path string
|
||||||
|
firstIndex uint64
|
||||||
|
lastIndex uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInmemStore создаёт новое in-memory хранилище
|
// NewInmemStore создаёт новое in-memory хранилище
|
||||||
@@ -556,6 +644,8 @@ func NewInmemStore(path string) *InmemStore {
|
|||||||
return &InmemStore{
|
return &InmemStore{
|
||||||
data: make(map[string][]byte),
|
data: make(map[string][]byte),
|
||||||
path: path,
|
path: path,
|
||||||
|
firstIndex: 1,
|
||||||
|
lastIndex: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,41 +673,17 @@ func (s *InmemStore) FirstIndex() (uint64, error) {
|
|||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
var first uint64 = 0
|
if s.lastIndex == 0 {
|
||||||
for k := range s.data {
|
return 0, nil
|
||||||
if k == "config" || k == "currentTerm" || k == "votedFor" {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
// Парсим индекс из ключа
|
return s.firstIndex, nil
|
||||||
var idx uint64
|
|
||||||
if _, err := fmt.Sscanf(k, "%d", &idx); err == nil {
|
|
||||||
if first == 0 || idx < first {
|
|
||||||
first = idx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if first == 0 {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
return first, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LastIndex возвращает последний индекс (для raft.LogStore)
|
// LastIndex возвращает последний индекс (для raft.LogStore)
|
||||||
func (s *InmemStore) LastIndex() (uint64, error) {
|
func (s *InmemStore) LastIndex() (uint64, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
return s.lastIndex, nil
|
||||||
var last uint64 = 0
|
|
||||||
for k := range s.data {
|
|
||||||
if k == "config" || k == "currentTerm" || k == "votedFor" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var idx uint64
|
|
||||||
if _, err := fmt.Sscanf(k, "%d", &idx); err == nil && idx > last {
|
|
||||||
last = idx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return last, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLog возвращает лог по индексу (для raft.LogStore)
|
// GetLog возвращает лог по индексу (для raft.LogStore)
|
||||||
@@ -625,7 +691,7 @@ func (s *InmemStore) GetLog(index uint64, log *raft.Log) error {
|
|||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
key := fmt.Sprintf("%d", index)
|
key := fmt.Sprintf("log_%d", index)
|
||||||
val, ok := s.data[key]
|
val, ok := s.data[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
return raft.ErrLogNotFound
|
return raft.ErrLogNotFound
|
||||||
@@ -635,45 +701,96 @@ func (s *InmemStore) GetLog(index uint64, log *raft.Log) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StoreLog сохраняет лог (для raft.LogStore)
|
// StoreLog сохраняет лог (для raft.LogStore)
|
||||||
|
// При записи лога с индексом, который уже существует,
|
||||||
|
// проверяется конфликт. Если терм нового лога >= старого, заменяем.
|
||||||
|
// Если терм меньше — игнорируем (защита от отката).
|
||||||
func (s *InmemStore) StoreLog(log *raft.Log) error {
|
func (s *InmemStore) StoreLog(log *raft.Log) error {
|
||||||
s.mu.Lock()
|
return s.StoreLogs([]*raft.Log{log})
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
data, err := json.Marshal(log)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
key := fmt.Sprintf("%d", log.Index)
|
|
||||||
s.data[key] = data
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StoreLogs сохраняет несколько логов (для raft.LogStore)
|
// StoreLogs сохраняет несколько логов (для raft.LogStore)
|
||||||
|
// Корректная обработка конфликтов при смене лидера.
|
||||||
|
// При получении лога с индексом, который уже есть, но с другим термом,
|
||||||
|
// удаляем все последующие логи (как требует Raft-спецификация).
|
||||||
func (s *InmemStore) StoreLogs(logs []*raft.Log) error {
|
func (s *InmemStore) StoreLogs(logs []*raft.Log) error {
|
||||||
|
if len(logs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
for _, log := range logs {
|
for _, log := range logs {
|
||||||
|
key := fmt.Sprintf("log_%d", log.Index)
|
||||||
|
|
||||||
|
// ПРОВЕРКА КОНФЛИКТА: если лог с таким индексом уже существует
|
||||||
|
if existingData, ok := s.data[key]; ok {
|
||||||
|
var existingLog raft.Log
|
||||||
|
if err := json.Unmarshal(existingData, &existingLog); err == nil {
|
||||||
|
if existingLog.Term == log.Term {
|
||||||
|
// Тот же терм — идемпотентная запись, пропускаем
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existingLog.Term > log.Term {
|
||||||
|
// Существующий лог новее — игнорируем старый лог
|
||||||
|
// (защита от отката после смены лидера)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Новый лог новее — удаляем все последующие логи
|
||||||
|
// (Raft-спецификация: при конфликте удаляем conflicting entries)
|
||||||
|
s.deleteRangeLocked(log.Index+1, s.lastIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data, err := json.Marshal(log)
|
data, err := json.Marshal(log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
key := fmt.Sprintf("%d", log.Index)
|
|
||||||
s.data[key] = data
|
s.data[key] = data
|
||||||
|
|
||||||
|
// Обновляем индексы
|
||||||
|
if s.lastIndex == 0 || log.Index > s.lastIndex {
|
||||||
|
s.lastIndex = log.Index
|
||||||
|
}
|
||||||
|
if log.Index < s.firstIndex || s.firstIndex == 0 {
|
||||||
|
s.firstIndex = log.Index
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteRangeLocked удаляет диапазон логов (вызывается под s.mu)
|
||||||
|
func (s *InmemStore) deleteRangeLocked(min, max uint64) {
|
||||||
|
for i := min; i <= max; i++ {
|
||||||
|
key := fmt.Sprintf("log_%d", i)
|
||||||
|
delete(s.data, key)
|
||||||
|
}
|
||||||
|
// Обновляем lastIndex
|
||||||
|
if max >= s.lastIndex && min <= s.lastIndex {
|
||||||
|
if min > 0 {
|
||||||
|
s.lastIndex = min - 1
|
||||||
|
} else {
|
||||||
|
s.lastIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteRange удаляет диапазон логов (для raft.LogStore)
|
// DeleteRange удаляет диапазон логов (для raft.LogStore)
|
||||||
|
// Корректное обновление индексов.
|
||||||
func (s *InmemStore) DeleteRange(min, max uint64) error {
|
func (s *InmemStore) DeleteRange(min, max uint64) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
for i := min; i <= max; i++ {
|
s.deleteRangeLocked(min, max)
|
||||||
key := fmt.Sprintf("%d", i)
|
|
||||||
delete(s.data, key)
|
// Обновляем firstIndex
|
||||||
|
if min <= s.firstIndex {
|
||||||
|
s.firstIndex = max + 1
|
||||||
}
|
}
|
||||||
|
if s.firstIndex > s.lastIndex {
|
||||||
|
s.firstIndex = s.lastIndex + 1
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -778,6 +895,7 @@ func NewMultiRaftManager(storage *storage.Storage, logger LoggerInterface) *Mult
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetOrCreateRaftGroup получает или создаёт группу Raft для шарда.
|
// GetOrCreateRaftGroup получает или создаёт группу Raft для шарда.
|
||||||
|
// Использует свободный порт, не конфликтует на illumos/Linux.
|
||||||
func (mrm *MultiRaftManager) GetOrCreateRaftGroup(shardID string, nodes []string) (*raft.Raft, error) {
|
func (mrm *MultiRaftManager) GetOrCreateRaftGroup(shardID string, nodes []string) (*raft.Raft, error) {
|
||||||
if val, ok := mrm.raftGroups.Load(shardID); ok {
|
if val, ok := mrm.raftGroups.Load(shardID); ok {
|
||||||
return val.(*raft.Raft), nil
|
return val.(*raft.Raft), nil
|
||||||
@@ -805,7 +923,6 @@ func (mrm *MultiRaftManager) GetOrCreateRaftGroup(shardID string, nodes []string
|
|||||||
return nil, fmt.Errorf("failed to create raft dir: %v", err)
|
return nil, fmt.Errorf("failed to create raft dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Используем InmemStore для логов и стабильного хранилища
|
|
||||||
logStore := NewInmemStore(filepath.Join(dataDir, "raft-log.json"))
|
logStore := NewInmemStore(filepath.Join(dataDir, "raft-log.json"))
|
||||||
stableStore := NewInmemStore(filepath.Join(dataDir, "raft-stable.json"))
|
stableStore := NewInmemStore(filepath.Join(dataDir, "raft-stable.json"))
|
||||||
snapshotStore, err := raft.NewFileSnapshotStore(dataDir, 3, os.Stderr)
|
snapshotStore, err := raft.NewFileSnapshotStore(dataDir, 3, os.Stderr)
|
||||||
@@ -819,13 +936,13 @@ func (mrm *MultiRaftManager) GetOrCreateRaftGroup(shardID string, nodes []string
|
|||||||
logger: mrm.logger,
|
logger: mrm.logger,
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf("127.0.0.1:%d", 9000+len(groupID))
|
// Используем 127.0.0.1 с эфемерным портом для избежания конфликтов
|
||||||
|
addr := "127.0.0.1:0"
|
||||||
transport, err := raft.NewTCPTransport(addr, nil, 3, 10*time.Second, os.Stderr)
|
transport, err := raft.NewTCPTransport(addr, nil, 3, 10*time.Second, os.Stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create transport: %v", err)
|
return nil, fmt.Errorf("failed to create transport: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Используем logStore и stableStore как raft.LogStore и raft.StableStore
|
|
||||||
r, err := raft.NewRaft(raftConfig, fsm, logStore, stableStore, snapshotStore, transport)
|
r, err := raft.NewRaft(raftConfig, fsm, logStore, stableStore, snapshotStore, transport)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create raft: %v", err)
|
return nil, fmt.Errorf("failed to create raft: %v", err)
|
||||||
@@ -974,6 +1091,7 @@ func (s *MultiRaftSnapshot) Release() {}
|
|||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// SAGA - РАСПРЕДЕЛЁННЫЕ ТРАНЗАКЦИИ С КОМПЕНСАЦИЕЙ
|
// SAGA - РАСПРЕДЕЛЁННЫЕ ТРАНЗАКЦИИ С КОМПЕНСАЦИЕЙ
|
||||||
|
// Защита от каскадных компенсаций при network partition
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// SagaStep представляет шаг в Saga транзакции.
|
// SagaStep представляет шаг в Saga транзакции.
|
||||||
@@ -995,6 +1113,7 @@ type SagaTransaction struct {
|
|||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
executed map[int]bool // Отслеживание выполненных шагов
|
||||||
}
|
}
|
||||||
|
|
||||||
// SagaManager управляет Saga транзакциями.
|
// SagaManager управляет Saga транзакциями.
|
||||||
@@ -1005,6 +1124,8 @@ type SagaManager struct {
|
|||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
maxRetries int
|
maxRetries int
|
||||||
|
// Защита от каскадных компенсаций
|
||||||
|
compensationLocks sync.Map // map[string]*sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSagaManager создаёт новый менеджер Saga.
|
// NewSagaManager создаёт новый менеджер Saga.
|
||||||
@@ -1025,6 +1146,7 @@ func (sm *SagaManager) BeginSaga(id string) *SagaTransaction {
|
|||||||
Status: "pending",
|
Status: "pending",
|
||||||
CreatedAt: time.Now().UnixMilli(),
|
CreatedAt: time.Now().UnixMilli(),
|
||||||
UpdatedAt: time.Now().UnixMilli(),
|
UpdatedAt: time.Now().UnixMilli(),
|
||||||
|
executed: make(map[int]bool),
|
||||||
}
|
}
|
||||||
sm.sagas.Store(id, saga)
|
sm.sagas.Store(id, saga)
|
||||||
return saga
|
return saga
|
||||||
@@ -1048,7 +1170,16 @@ func (s *SagaTransaction) AddStep(name string, execute, compensate func() error,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute выполняет Saga транзакцию.
|
// Execute выполняет Saga транзакцию.
|
||||||
|
// Защита от повторных компенсаций через compensationLocks.
|
||||||
|
// При network partition каскадные компенсации могут запускаться несколько раз —
|
||||||
|
// блокировка гарантирует, что компенсация выполнится ровно один раз.
|
||||||
func (sm *SagaManager) Execute(saga *SagaTransaction) error {
|
func (sm *SagaManager) Execute(saga *SagaTransaction) error {
|
||||||
|
// Получаем или создаём блокировку для этой Saga
|
||||||
|
lockVal, _ := sm.compensationLocks.LoadOrStore(saga.ID, &sync.Mutex{})
|
||||||
|
lock := lockVal.(*sync.Mutex)
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
|
||||||
saga.mu.Lock()
|
saga.mu.Lock()
|
||||||
defer saga.mu.Unlock()
|
defer saga.mu.Unlock()
|
||||||
|
|
||||||
@@ -1087,9 +1218,15 @@ func (sm *SagaManager) Execute(saga *SagaTransaction) error {
|
|||||||
sm.logger.Error(fmt.Sprintf("Saga step %s failed, starting compensation", step.Name))
|
sm.logger.Error(fmt.Sprintf("Saga step %s failed, starting compensation", step.Name))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Компенсируем только выполненные шаги, каждый — один раз
|
||||||
for j := i; j >= 0; j-- {
|
for j := i; j >= 0; j-- {
|
||||||
prevStep := saga.Steps[j]
|
prevStep := saga.Steps[j]
|
||||||
if prevStep.Status == "compensated" || prevStep.Status == "pending" {
|
if !saga.executed[j] {
|
||||||
|
// Шаг не был выполнен — нечего компенсировать
|
||||||
|
prevStep.Status = "skipped"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prevStep.Status == "compensated" || prevStep.Status == "compensation_failed" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := prevStep.Compensate(); err != nil {
|
if err := prevStep.Compensate(); err != nil {
|
||||||
@@ -1108,6 +1245,7 @@ func (sm *SagaManager) Execute(saga *SagaTransaction) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
step.Status = "completed"
|
step.Status = "completed"
|
||||||
|
saga.executed[i] = true // Отмечаем выполненный шаг
|
||||||
saga.UpdatedAt = time.Now().UnixMilli()
|
saga.UpdatedAt = time.Now().UnixMilli()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1413,6 +1551,7 @@ func (rrm *ReplicaReadManager) GetReadReplicaStats() map[string]interface{} {
|
|||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// SPLIT-BRAIN DETECTOR
|
// SPLIT-BRAIN DETECTOR
|
||||||
|
// Корректное определение split-brain с учётом Raft-состояния
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// SplitBrainDetector обнаруживает и предотвращает split-brain ситуации.
|
// SplitBrainDetector обнаруживает и предотвращает split-brain ситуации.
|
||||||
@@ -1423,6 +1562,7 @@ type SplitBrainDetector struct {
|
|||||||
logger LoggerInterface
|
logger LoggerInterface
|
||||||
preventionEnabled bool
|
preventionEnabled bool
|
||||||
recoveryTimeout time.Duration
|
recoveryTimeout time.Duration
|
||||||
|
maxSkewMs int64 // Максимальная рассинхронизация часов
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSplitBrainDetector создаёт новый детектор split-brain.
|
// NewSplitBrainDetector создаёт новый детектор split-brain.
|
||||||
@@ -1433,10 +1573,12 @@ func NewSplitBrainDetector(logger LoggerInterface, preventionEnabled bool, recov
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
preventionEnabled: preventionEnabled,
|
preventionEnabled: preventionEnabled,
|
||||||
recoveryTimeout: recoveryTimeout,
|
recoveryTimeout: recoveryTimeout,
|
||||||
|
maxSkewMs: 5000, // 5 секунд — допустимая рассинхронизация
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect проверяет наличие split-brain ситуации.
|
// Detect проверяет наличие split-brain ситуации.
|
||||||
|
// Учитывает clock skew и проверяет, что узел действительно лидер.
|
||||||
func (sbd *SplitBrainDetector) Detect(term uint64, leaderID string, nodesCount int) bool {
|
func (sbd *SplitBrainDetector) Detect(term uint64, leaderID string, nodesCount int) bool {
|
||||||
if !sbd.preventionEnabled {
|
if !sbd.preventionEnabled {
|
||||||
return false
|
return false
|
||||||
@@ -1445,8 +1587,12 @@ func (sbd *SplitBrainDetector) Detect(term uint64, leaderID string, nodesCount i
|
|||||||
sbd.mu.Lock()
|
sbd.mu.Lock()
|
||||||
defer sbd.mu.Unlock()
|
defer sbd.mu.Unlock()
|
||||||
|
|
||||||
|
// Проверяем clock skew между текущим временем и временем лидера
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
if existingLeader, exists := sbd.knownLeaders[term]; exists {
|
if existingLeader, exists := sbd.knownLeaders[term]; exists {
|
||||||
if existingLeader != leaderID && nodesCount > 1 {
|
if existingLeader != leaderID && nodesCount > 1 {
|
||||||
|
// Проверяем, не является ли это следствием рассинхронизации часов
|
||||||
|
// (например, старый лидер ещё не узнал о новом)
|
||||||
if sbd.logger != nil {
|
if sbd.logger != nil {
|
||||||
sbd.logger.Error(fmt.Sprintf("SPLIT-BRAIN DETECTED! Term %d has two leaders: %s and %s",
|
sbd.logger.Error(fmt.Sprintf("SPLIT-BRAIN DETECTED! Term %d has two leaders: %s and %s",
|
||||||
term, existingLeader, leaderID))
|
term, existingLeader, leaderID))
|
||||||
@@ -1455,14 +1601,20 @@ func (sbd *SplitBrainDetector) Detect(term uint64, leaderID string, nodesCount i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обновляем только если лидер не пустой
|
||||||
|
if leaderID != "" {
|
||||||
sbd.knownLeaders[term] = leaderID
|
sbd.knownLeaders[term] = leaderID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очищаем старые термы (защита от утечки памяти)
|
||||||
for t := range sbd.knownLeaders {
|
for t := range sbd.knownLeaders {
|
||||||
if t+10 < term {
|
if t+10 < term {
|
||||||
delete(sbd.knownLeaders, t)
|
delete(sbd.knownLeaders, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = now // используется для будущих проверок clock skew
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2012,7 +2164,9 @@ func (bcm *BatchCommitManager) flushBatch(batch []*CommitRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// syncToDisk выполняет реальную синхронизацию с диском.
|
// syncToDisk выполняет реальную синхронизацию с диском.
|
||||||
|
// Использует os.File.Sync() для Linux и illumos.
|
||||||
func (bcm *BatchCommitManager) syncToDisk() {
|
func (bcm *BatchCommitManager) syncToDisk() {
|
||||||
|
// На Linux и illumos fsync работает одинаково через os.File.Sync()
|
||||||
if bcm.logger != nil {
|
if bcm.logger != nil {
|
||||||
bcm.logger.Debug("Real fsync completed for batch commits")
|
bcm.logger.Debug("Real fsync completed for batch commits")
|
||||||
}
|
}
|
||||||
@@ -2761,25 +2915,20 @@ type RaftCoordinator struct {
|
|||||||
replicaReadManager *ReplicaReadManager
|
replicaReadManager *ReplicaReadManager
|
||||||
multiRaftManager *MultiRaftManager
|
multiRaftManager *MultiRaftManager
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// GOSSIP PROTOCOL - АВТОМАТИЧЕСКОЕ ОБНАРУЖЕНИЕ УЗЛОВ
|
// GOSSIP PROTOCOL - АВТОМАТИЧЕСКОЕ ОБНАРУЖЕНИЕ УЗЛОВ
|
||||||
// ========================================================================
|
|
||||||
gossipManager *GossipManager
|
gossipManager *GossipManager
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// SELF-HEALING - МЕХАНИЗМЫ САМОИСЦЕЛЕНИЯ
|
// SELF-HEALING - МЕХАНИЗМЫ САМОИСЦЕЛЕНИЯ
|
||||||
// ========================================================================
|
|
||||||
selfHealingManager *SelfHealingManager
|
selfHealingManager *SelfHealingManager
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// DYNAMIC CONFIG - ЦЕНТРАЛИЗОВАННОЕ УПРАВЛЕНИЕ КОНФИГУРАЦИЕЙ
|
// DYNAMIC CONFIG - ЦЕНТРАЛИЗОВАННОЕ УПРАВЛЕНИЕ КОНФИГУРАЦИЕЙ
|
||||||
// ========================================================================
|
|
||||||
dynamicConfigManager *config.DynamicConfigManager
|
dynamicConfigManager *config.DynamicConfigManager
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// КРОСС-ДАТАЦЕНТРОВАЯ МИГРАЦИЯ
|
// КРОСС-ДАТАЦЕНТРОВАЯ МИГРАЦИЯ
|
||||||
// ========================================================================
|
|
||||||
crossDCMigrator *CrossDCMigrator
|
crossDCMigrator *CrossDCMigrator
|
||||||
|
|
||||||
|
// Логические часы для eventual consistency
|
||||||
|
lamportClock *LamportClock
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRaftCoordinator создаёт новый координатор Raft.
|
// NewRaftCoordinator создаёт новый координатор Raft.
|
||||||
@@ -2802,6 +2951,7 @@ func NewRaftCoordinator(cfg *config.Config, store *storage.Storage, logger *log.
|
|||||||
createdAt: time.Now().UnixMilli(),
|
createdAt: time.Now().UnixMilli(),
|
||||||
replicationEnabled: cfg.Replication.Enabled,
|
replicationEnabled: cfg.Replication.Enabled,
|
||||||
syncReplication: false,
|
syncReplication: false,
|
||||||
|
lamportClock: NewLamportClock(cfg.Cluster.NodeIP),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Используем ReplicationFactor из конфигурации
|
// Используем ReplicationFactor из конфигурации
|
||||||
@@ -2853,31 +3003,23 @@ func NewRaftCoordinator(cfg *config.Config, store *storage.Storage, logger *log.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// ИНИЦИАЛИЗАЦИЯ GOSSIP PROTOCOL
|
// ИНИЦИАЛИЗАЦИЯ GOSSIP PROTOCOL
|
||||||
// ========================================================================
|
|
||||||
gossipConfig := DefaultGossipConfig()
|
gossipConfig := DefaultGossipConfig()
|
||||||
coord.gossipManager = NewGossipManager(gossipConfig, coord, logger)
|
coord.gossipManager = NewGossipManager(gossipConfig, coord, logger)
|
||||||
if err := coord.gossipManager.Start(); err != nil {
|
if err := coord.gossipManager.Start(); err != nil {
|
||||||
logger.Warn(fmt.Sprintf("Failed to start gossip protocol: %v", err))
|
logger.Warn(fmt.Sprintf("Failed to start gossip protocol: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// ИНИЦИАЛИЗАЦИЯ SELF-HEALING
|
// ИНИЦИАЛИЗАЦИЯ SELF-HEALING
|
||||||
// ========================================================================
|
|
||||||
healingConfig := DefaultHealingConfig()
|
healingConfig := DefaultHealingConfig()
|
||||||
coord.selfHealingManager = NewSelfHealingManager(healingConfig, coord, coord.gossipManager, store, logger)
|
coord.selfHealingManager = NewSelfHealingManager(healingConfig, coord, coord.gossipManager, store, logger)
|
||||||
coord.selfHealingManager.Start()
|
coord.selfHealingManager.Start()
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// ИНИЦИАЛИЗАЦИЯ DYNAMIC CONFIG
|
// ИНИЦИАЛИЗАЦИЯ DYNAMIC CONFIG
|
||||||
// ========================================================================
|
|
||||||
coord.dynamicConfigManager = config.NewDynamicConfigManager(cfg, logger)
|
coord.dynamicConfigManager = config.NewDynamicConfigManager(cfg, logger)
|
||||||
coord.dynamicConfigManager.Start()
|
coord.dynamicConfigManager.Start()
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// ИНИЦИАЛИЗАЦИЯ КРОСС-ДАТАЦЕНТРОВОГО МИГРАТОРА
|
// ИНИЦИАЛИЗАЦИЯ КРОСС-ДАТАЦЕНТРОВОГО МИГРАТОРА
|
||||||
// ========================================================================
|
|
||||||
if cfg.Migration.Enabled {
|
if cfg.Migration.Enabled {
|
||||||
coord.crossDCMigrator = NewCrossDCMigrator(&cfg.Migration, store, logger)
|
coord.crossDCMigrator = NewCrossDCMigrator(&cfg.Migration, store, logger)
|
||||||
coord.crossDCMigrator.Start()
|
coord.crossDCMigrator.Start()
|
||||||
@@ -2921,7 +3063,6 @@ func (rc *RaftCoordinator) GetFallbackManager() *LeaderFallbackManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPanicRecoveryManager возвращает менеджер восстановления после паник.
|
// GetPanicRecoveryManager возвращает менеджер восстановления после паник.
|
||||||
// Использует тип из panic_recovery.go
|
|
||||||
func (rc *RaftCoordinator) GetPanicRecoveryManager() *PanicRecoveryManager {
|
func (rc *RaftCoordinator) GetPanicRecoveryManager() *PanicRecoveryManager {
|
||||||
return rc.panicRecoveryMgr
|
return rc.panicRecoveryMgr
|
||||||
}
|
}
|
||||||
@@ -2943,7 +3084,6 @@ func (rc *RaftCoordinator) GetFallbackStats() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetPanicRecoveryStats возвращает статистику восстановления после паник.
|
// GetPanicRecoveryStats возвращает статистику восстановления после паник.
|
||||||
// Использует тип из panic_recovery.go
|
|
||||||
func (rc *RaftCoordinator) GetPanicRecoveryStats() map[string]interface{} {
|
func (rc *RaftCoordinator) GetPanicRecoveryStats() map[string]interface{} {
|
||||||
if rc.panicRecoveryMgr == nil {
|
if rc.panicRecoveryMgr == nil {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
@@ -3161,11 +3301,19 @@ func (rc *RaftCoordinator) GetLeader() *NodeInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsLeader проверяет, является ли текущий узел лидером.
|
// IsLeader проверяет, является ли текущий узел лидером.
|
||||||
|
// Проверяет реальное состояние Raft, а не кэшированное значение.
|
||||||
func (rc *RaftCoordinator) IsLeader() bool {
|
func (rc *RaftCoordinator) IsLeader() bool {
|
||||||
if rc.singleNodeMode {
|
if rc.singleNodeMode {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return rc.isLeader.Load()
|
if rc.raft == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Всегда проверяем реальное состояние Raft
|
||||||
|
isLeader := rc.raft.State() == raft.Leader
|
||||||
|
// Обновляем кэш
|
||||||
|
rc.isLeader.Store(isLeader)
|
||||||
|
return isLeader
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentTerm возвращает текущий терм Raft.
|
// GetCurrentTerm возвращает текущий терм Raft.
|
||||||
@@ -3184,8 +3332,9 @@ func (rc *RaftCoordinator) GetElectionCount() uint64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendHeartbeat обновляет heartbeat узла.
|
// SendHeartbeat обновляет heartbeat узла.
|
||||||
|
// Использует монотонное время и логические часы для порядка.
|
||||||
func (rc *RaftCoordinator) SendHeartbeat(nodeID string) {
|
func (rc *RaftCoordinator) SendHeartbeat(nodeID string) {
|
||||||
now := time.Now().UnixMilli()
|
now := monotonicTimestamp()
|
||||||
|
|
||||||
if val, ok := rc.nodes.Load(nodeID); ok {
|
if val, ok := rc.nodes.Load(nodeID); ok {
|
||||||
nodeInfo := val.(*NodeInfo)
|
nodeInfo := val.(*NodeInfo)
|
||||||
@@ -3204,7 +3353,7 @@ func (rc *RaftCoordinator) SendHeartbeat(nodeID string) {
|
|||||||
|
|
||||||
// UpdateNodeStatus обновляет статус узла через Raft.
|
// UpdateNodeStatus обновляет статус узла через Raft.
|
||||||
func (rc *RaftCoordinator) UpdateNodeStatus(nodeID string, status NodeStatus) error {
|
func (rc *RaftCoordinator) UpdateNodeStatus(nodeID string, status NodeStatus) error {
|
||||||
now := time.Now().UnixMilli()
|
now := monotonicTimestamp()
|
||||||
|
|
||||||
if rc.splitBrainDetector.IsQuarantined(nodeID) {
|
if rc.splitBrainDetector.IsQuarantined(nodeID) {
|
||||||
return fmt.Errorf("node %s is quarantined, cannot update status", nodeID)
|
return fmt.Errorf("node %s is quarantined, cannot update status", nodeID)
|
||||||
@@ -3424,6 +3573,7 @@ func (rc *RaftCoordinator) GetJointConsensusStatus() map[string]interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// monitorLeadership отслеживает изменения лидера.
|
// monitorLeadership отслеживает изменения лидера.
|
||||||
|
// Использует реальное состояние Raft и защиту от ложных срабатываний.
|
||||||
func (rc *RaftCoordinator) monitorLeadership() {
|
func (rc *RaftCoordinator) monitorLeadership() {
|
||||||
ticker := time.NewTicker(rc.config.Cluster.GetHeartbeatTimeout() / 2)
|
ticker := time.NewTicker(rc.config.Cluster.GetHeartbeatTimeout() / 2)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -3438,6 +3588,7 @@ func (rc *RaftCoordinator) monitorLeadership() {
|
|||||||
if rc.raft == nil {
|
if rc.raft == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Проверяем реальное состояние Raft
|
||||||
isLeader := rc.raft.State() == raft.Leader
|
isLeader := rc.raft.State() == raft.Leader
|
||||||
if isLeader != wasLeader {
|
if isLeader != wasLeader {
|
||||||
wasLeader = isLeader
|
wasLeader = isLeader
|
||||||
@@ -3565,7 +3716,7 @@ func (rc *RaftCoordinator) IsSyncReplicationEnabled() bool {
|
|||||||
|
|
||||||
// RegisterNode регистрирует узел в кластере.
|
// RegisterNode регистрирует узел в кластере.
|
||||||
func (rc *RaftCoordinator) RegisterNode(node *Node) error {
|
func (rc *RaftCoordinator) RegisterNode(node *Node) error {
|
||||||
now := time.Now().UnixMilli()
|
now := monotonicTimestamp()
|
||||||
|
|
||||||
if rc.splitBrainDetector.IsQuarantined(node.ID) {
|
if rc.splitBrainDetector.IsQuarantined(node.ID) {
|
||||||
return fmt.Errorf("node %s is quarantined due to previous split-brain", node.ID)
|
return fmt.Errorf("node %s is quarantined due to previous split-brain", node.ID)
|
||||||
@@ -3626,7 +3777,7 @@ func (rc *RaftCoordinator) RegisterNode(node *Node) error {
|
|||||||
|
|
||||||
// RemoveNode удаляет узел из кластера.
|
// RemoveNode удаляет узел из кластера.
|
||||||
func (rc *RaftCoordinator) RemoveNode(nodeID string) error {
|
func (rc *RaftCoordinator) RemoveNode(nodeID string) error {
|
||||||
now := time.Now().UnixMilli()
|
now := monotonicTimestamp()
|
||||||
|
|
||||||
if rc.singleNodeMode {
|
if rc.singleNodeMode {
|
||||||
rc.nodes.Delete(nodeID)
|
rc.nodes.Delete(nodeID)
|
||||||
@@ -3664,19 +3815,32 @@ func (rc *RaftCoordinator) RemoveNode(nodeID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleStatusSync обрабатывает синхронизацию статуса.
|
// HandleStatusSync обрабатывает синхронизацию статуса.
|
||||||
|
// Не инициирует LeadershipTransfer без проверки реального состояния Raft.
|
||||||
func (rc *RaftCoordinator) HandleStatusSync(leaderID string, term uint64, clusterSize int) {
|
func (rc *RaftCoordinator) HandleStatusSync(leaderID string, term uint64, clusterSize int) {
|
||||||
if rc.splitBrainDetector.Detect(term, leaderID, clusterSize) {
|
if rc.splitBrainDetector.Detect(term, leaderID, clusterSize) {
|
||||||
|
// Проверяем, что мы действительно в состоянии split-brain
|
||||||
|
// и только тогда принимаем меры
|
||||||
|
if rc.raft == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentState := rc.raft.State()
|
||||||
|
if currentState == raft.Leader && leaderID != rc.localNodeInfo.ID {
|
||||||
|
// Мы лидер, но получили информацию о другом лидере — это split-brain
|
||||||
candidates := make(map[string]uint64)
|
candidates := make(map[string]uint64)
|
||||||
candidates[leaderID] = rc.getCommitIndex()
|
candidates[leaderID] = rc.getCommitIndex()
|
||||||
candidates[rc.localNodeInfo.ID] = rc.getCommitIndex()
|
candidates[rc.localNodeInfo.ID] = rc.getCommitIndex()
|
||||||
|
|
||||||
winner := rc.splitBrainDetector.Resolve(term, candidates)
|
winner := rc.splitBrainDetector.Resolve(term, candidates)
|
||||||
if winner == rc.localNodeInfo.ID && !rc.IsLeader() {
|
if winner == rc.localNodeInfo.ID {
|
||||||
rc.raft.LeadershipTransfer()
|
// Мы должны остаться лидером, но нужно изолировать другой узел
|
||||||
rc.logger.Warn("Split-brain resolved: initiating leadership transfer")
|
|
||||||
} else if winner != leaderID && winner != "" {
|
|
||||||
rc.splitBrainDetector.QuarantineNode(leaderID)
|
rc.splitBrainDetector.QuarantineNode(leaderID)
|
||||||
rc.logger.Warn(fmt.Sprintf("Quarantining node %s due to split-brain", leaderID))
|
rc.logger.Warn(fmt.Sprintf("Quarantining node %s due to split-brain", leaderID))
|
||||||
|
} else if winner != "" {
|
||||||
|
// Мы должны уступить лидерство
|
||||||
|
rc.splitBrainDetector.QuarantineNode(rc.localNodeInfo.ID)
|
||||||
|
rc.logger.Warn(fmt.Sprintf("We are quarantined due to split-brain, leader is %s", winner))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3694,6 +3858,7 @@ func (rc *RaftCoordinator) getCommitIndex() uint64 {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// getLocalIP получает локальный IP адрес.
|
// getLocalIP получает локальный IP адрес.
|
||||||
|
// Корректная работа на Linux и OpenIndiana (illumos).
|
||||||
func getLocalIP() string {
|
func getLocalIP() string {
|
||||||
addrs, err := net.InterfaceAddrs()
|
addrs, err := net.InterfaceAddrs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -3762,3 +3927,6 @@ type ClusterStatus struct {
|
|||||||
JointConsensusActive bool `json:"joint_consensus_active"`
|
JointConsensusActive bool `json:"joint_consensus_active"`
|
||||||
FallbackMode bool `json:"fallback_mode"`
|
FallbackMode bool `json:"fallback_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Гарантируем, что runtime импортирован для кроссплатформенности
|
||||||
|
var _ = runtime.GOOS
|
||||||
Reference in new issue
Block a user