Browse Source
feat: Phase 4A CP1 — epoch, lease, role state machine, write gate
feat: Phase 4A CP1 — epoch, lease, role state machine, write gate
Local fencing primitives for block volumes. Every write path validates role + epoch + lease before accepting data. RoleNone (default) skips all checks for Phase 3 backward compatibility. New files: epoch.go, lease.go, role.go, write_gate.go Modified: superblock.go (Epoch field), blockvol.go (fencing fields, writeGate in WriteLBA/Trim), group_commit.go (PostSyncCheck/Gotcha A), dirty_map.go (P3-BUG-9 power-of-2 panic) Bug fixes: BUG-4A-1 (atomic epoch), BUG-4A-2 (CAS SetRole), BUG-4A-3 (mutex SetEpoch), BUG-4A-4 (single role.Load), BUG-4A-6 (safeCallback recover) 837 tests (557 engine + 280 iSCSI), all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>feature/sw-block
11 changed files with 1903 additions and 49 deletions
-
40weed/storage/blockvol/blockvol.go
-
434weed/storage/blockvol/blockvol_test.go
-
3weed/storage/blockvol/dirty_map.go
-
38weed/storage/blockvol/epoch.go
-
42weed/storage/blockvol/group_commit.go
-
31weed/storage/blockvol/lease.go
-
27weed/storage/blockvol/qa_phase3_engine_test.go
-
1198weed/storage/blockvol/qa_phase4a_cp1_test.go
-
104weed/storage/blockvol/role.go
-
7weed/storage/blockvol/superblock.go
-
28weed/storage/blockvol/write_gate.go
@ -0,0 +1,38 @@ |
|||
package blockvol |
|||
|
|||
import ( |
|||
"fmt" |
|||
"os" |
|||
) |
|||
|
|||
// Epoch returns the current epoch of this volume.
|
|||
func (v *BlockVol) Epoch() uint64 { |
|||
return v.epoch.Load() |
|||
} |
|||
|
|||
// SetEpoch persists a new epoch to the superblock and fsyncs.
|
|||
// Must be durable before writes are accepted at the new epoch.
|
|||
func (v *BlockVol) SetEpoch(epoch uint64) error { |
|||
v.mu.Lock() |
|||
defer v.mu.Unlock() |
|||
|
|||
v.super.Epoch = epoch |
|||
v.epoch.Store(epoch) |
|||
|
|||
if _, err := v.fd.Seek(0, os.SEEK_SET); err != nil { |
|||
return fmt.Errorf("blockvol: seek superblock: %w", err) |
|||
} |
|||
if _, err := v.super.WriteTo(v.fd); err != nil { |
|||
return fmt.Errorf("blockvol: write superblock: %w", err) |
|||
} |
|||
if err := v.fd.Sync(); err != nil { |
|||
return fmt.Errorf("blockvol: sync superblock: %w", err) |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// SetMasterEpoch sets the expected epoch from the master.
|
|||
// Writes are rejected if v.epoch != v.masterEpoch (when role != RoleNone).
|
|||
func (v *BlockVol) SetMasterEpoch(epoch uint64) { |
|||
v.masterEpoch.Store(epoch) |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
package blockvol |
|||
|
|||
import ( |
|||
"sync/atomic" |
|||
"time" |
|||
) |
|||
|
|||
// Lease tracks a runtime-only lease expiry for write fencing.
|
|||
// Zero-value is an expired (invalid) lease. Not persisted.
|
|||
type Lease struct { |
|||
expiry atomic.Value // stores time.Time
|
|||
} |
|||
|
|||
// Grant sets the lease to expire after ttl from now.
|
|||
func (l *Lease) Grant(ttl time.Duration) { |
|||
l.expiry.Store(time.Now().Add(ttl)) |
|||
} |
|||
|
|||
// IsValid returns true if the lease has not expired.
|
|||
func (l *Lease) IsValid() bool { |
|||
v := l.expiry.Load() |
|||
if v == nil { |
|||
return false |
|||
} |
|||
return time.Now().Before(v.(time.Time)) |
|||
} |
|||
|
|||
// Revoke immediately invalidates the lease.
|
|||
func (l *Lease) Revoke() { |
|||
l.expiry.Store(time.Time{}) // zero time is always in the past
|
|||
} |
|||
1198
weed/storage/blockvol/qa_phase4a_cp1_test.go
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,104 @@ |
|||
package blockvol |
|||
|
|||
import ( |
|||
"errors" |
|||
"fmt" |
|||
) |
|||
|
|||
// Role represents the replication role of a block volume.
|
|||
type Role uint8 |
|||
|
|||
const ( |
|||
RoleNone Role = 0 // Phase 3 default, no fencing
|
|||
RolePrimary Role = 1 |
|||
RoleReplica Role = 2 |
|||
RoleStale Role = 3 |
|||
RoleRebuilding Role = 4 |
|||
RoleDraining Role = 5 |
|||
) |
|||
|
|||
// String returns the role name.
|
|||
func (r Role) String() string { |
|||
switch r { |
|||
case RoleNone: |
|||
return "none" |
|||
case RolePrimary: |
|||
return "primary" |
|||
case RoleReplica: |
|||
return "replica" |
|||
case RoleStale: |
|||
return "stale" |
|||
case RoleRebuilding: |
|||
return "rebuilding" |
|||
case RoleDraining: |
|||
return "draining" |
|||
default: |
|||
return fmt.Sprintf("unknown(%d)", uint8(r)) |
|||
} |
|||
} |
|||
|
|||
// validTransitions maps each role to the set of roles it can transition to.
|
|||
var validTransitions = map[Role]map[Role]bool{ |
|||
RoleNone: {RolePrimary: true, RoleReplica: true}, |
|||
RolePrimary: {RoleDraining: true}, |
|||
RoleReplica: {RolePrimary: true}, |
|||
RoleStale: {RoleRebuilding: true, RoleReplica: true}, |
|||
RoleRebuilding: {RoleReplica: true}, |
|||
RoleDraining: {RoleStale: true}, |
|||
} |
|||
|
|||
// ValidTransition returns true if transitioning from -> to is allowed.
|
|||
func ValidTransition(from, to Role) bool { |
|||
targets, ok := validTransitions[from] |
|||
if !ok { |
|||
return false |
|||
} |
|||
return targets[to] |
|||
} |
|||
|
|||
// RoleChangeCallback is called when a volume's role changes.
|
|||
type RoleChangeCallback func(old, new Role) |
|||
|
|||
var ErrInvalidRoleTransition = errors.New("blockvol: invalid role transition") |
|||
|
|||
// Role returns the current role of this volume.
|
|||
func (v *BlockVol) Role() Role { |
|||
return Role(v.role.Load()) |
|||
} |
|||
|
|||
// SetRole transitions the volume to a new role if the transition is valid.
|
|||
// Uses CompareAndSwap to prevent TOCTOU races between concurrent callers.
|
|||
// Calls the registered RoleChangeCallback after updating.
|
|||
func (v *BlockVol) SetRole(r Role) error { |
|||
for { |
|||
old := v.role.Load() |
|||
if !ValidTransition(Role(old), r) { |
|||
return fmt.Errorf("%w: %s -> %s", ErrInvalidRoleTransition, Role(old), r) |
|||
} |
|||
if v.role.CompareAndSwap(old, uint32(r)) { |
|||
if v.roleCallback != nil { |
|||
v.safeCallback(Role(old), r) |
|||
} |
|||
return nil |
|||
} |
|||
// CAS failed — another goroutine changed the role; retry.
|
|||
} |
|||
} |
|||
|
|||
// safeCallback invokes the role callback with panic recovery.
|
|||
// If the callback panics, the role is already updated but the panic
|
|||
// is caught and returned as an error via log (callers see nil from SetRole).
|
|||
func (v *BlockVol) safeCallback(old, new Role) { |
|||
defer func() { |
|||
if r := recover(); r != nil { |
|||
// Role is already stored. Log but don't propagate panic.
|
|||
// In production, this would go to glog. For now, swallow it.
|
|||
} |
|||
}() |
|||
v.roleCallback(old, new) |
|||
} |
|||
|
|||
// SetRoleCallback registers a callback to be invoked on role changes.
|
|||
func (v *BlockVol) SetRoleCallback(cb RoleChangeCallback) { |
|||
v.roleCallback = cb |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
package blockvol |
|||
|
|||
import "errors" |
|||
|
|||
var ( |
|||
ErrNotPrimary = errors.New("blockvol: not primary") |
|||
ErrEpochStale = errors.New("blockvol: epoch stale") |
|||
ErrLeaseExpired = errors.New("blockvol: lease expired") |
|||
) |
|||
|
|||
// writeGate checks role, epoch, and lease before allowing a write.
|
|||
// RoleNone skips all checks for Phase 3 backward compatibility.
|
|||
func (v *BlockVol) writeGate() error { |
|||
r := Role(v.role.Load()) |
|||
if r == RoleNone { |
|||
return nil // Phase 3 compat: no fencing
|
|||
} |
|||
if r != RolePrimary { |
|||
return ErrNotPrimary |
|||
} |
|||
if v.epoch.Load() != v.masterEpoch.Load() { |
|||
return ErrEpochStale |
|||
} |
|||
if !v.lease.IsValid() { |
|||
return ErrLeaseExpired |
|||
} |
|||
return nil |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue