Skip to content

Commit 19c555b

Browse files
committed
feat(block): add generic StateTracker
A small two-state-plus-default tracker backed by roaring bitmaps. Used by upcoming UFFD work to track page states (Missing/Faulted/Removed) and by NBD to track zero pages, replacing ad-hoc map-based trackers with O(1) range ops and cheap snapshot exports.
1 parent 1af207c commit 19c555b

2 files changed

Lines changed: 180 additions & 0 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package block
2+
3+
import (
4+
"fmt"
5+
"sync"
6+
7+
"github.com/RoaringBitmap/roaring/v2"
8+
)
9+
10+
type StateTracker[S comparable] struct {
11+
mu sync.RWMutex
12+
13+
defaultState S
14+
a, b S
15+
bmA, bmB *roaring.Bitmap
16+
}
17+
18+
// NewStateTracker requires three distinct states. Duplicates are a
19+
// programming error — the switch in SetRange would silently favour the
20+
// first matching case and corrupt bitmap state — so we panic at
21+
// construction rather than defer the bug to a later SetRange call.
22+
func NewStateTracker[S comparable](defaultState, a, b S) *StateTracker[S] {
23+
if defaultState == a || defaultState == b || a == b {
24+
panic(fmt.Sprintf("block.NewStateTracker: states must be distinct (default=%v a=%v b=%v)", defaultState, a, b))
25+
}
26+
27+
return &StateTracker[S]{
28+
defaultState: defaultState,
29+
a: a,
30+
b: b,
31+
bmA: roaring.New(),
32+
bmB: roaring.New(),
33+
}
34+
}
35+
36+
// SetRange takes uint64 because roaring's range API allows end = 1<<32
37+
// (the half-open upper bound of a 32-bit bitmap); Get stays uint32 since
38+
// no 33-bit value can ever be a bitmap member.
39+
func (t *StateTracker[S]) SetRange(start, end uint64, state S) {
40+
if end <= start {
41+
return
42+
}
43+
44+
t.mu.Lock()
45+
defer t.mu.Unlock()
46+
47+
switch state {
48+
case t.a:
49+
t.bmA.AddRange(start, end)
50+
t.bmB.RemoveRange(start, end)
51+
case t.b:
52+
t.bmB.AddRange(start, end)
53+
t.bmA.RemoveRange(start, end)
54+
case t.defaultState:
55+
t.bmA.RemoveRange(start, end)
56+
t.bmB.RemoveRange(start, end)
57+
default:
58+
// S is constrained only to comparable, so the compiler can't
59+
// prove exhaustiveness. A silent no-op here would hide a
60+
// programming error (caller added a state but forgot to wire
61+
// it); panic makes it fail fast in tests.
62+
panic(fmt.Sprintf("block.StateTracker.SetRange: unsupported state %v (only default=%v a=%v b=%v allowed)", state, t.defaultState, t.a, t.b))
63+
}
64+
}
65+
66+
func (t *StateTracker[S]) Export() (a, b *roaring.Bitmap) {
67+
t.mu.RLock()
68+
defer t.mu.RUnlock()
69+
70+
return t.bmA.Clone(), t.bmB.Clone()
71+
}
72+
73+
func (t *StateTracker[S]) Get(idx uint32) S {
74+
t.mu.RLock()
75+
defer t.mu.RUnlock()
76+
77+
switch {
78+
case t.bmA.Contains(idx):
79+
return t.a
80+
case t.bmB.Contains(idx):
81+
return t.b
82+
default:
83+
return t.defaultState
84+
}
85+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package block
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
type ts uint8
10+
11+
const (
12+
tsDefault ts = iota
13+
tsA
14+
tsB
15+
)
16+
17+
// TestStateTracker exercises every transition pair (default↔a, default↔b,
18+
// a↔b, idempotent same-state) and confirms the two non-default bitmaps
19+
// stay disjoint.
20+
func TestStateTracker(t *testing.T) {
21+
t.Parallel()
22+
23+
t.Run("transitions", func(t *testing.T) {
24+
t.Parallel()
25+
s := NewStateTracker(tsDefault, tsA, tsB)
26+
27+
s.SetRange(0, 1, tsA)
28+
assert.Equal(t, tsA, s.Get(0))
29+
30+
s.SetRange(0, 1, tsB)
31+
assert.Equal(t, tsB, s.Get(0), "a→b should flip the page")
32+
bmA, bmB := s.Export()
33+
assert.False(t, bmA.Contains(0), "a→b must clear bmA")
34+
assert.True(t, bmB.Contains(0), "a→b must add to bmB")
35+
36+
s.SetRange(0, 1, tsA)
37+
assert.Equal(t, tsA, s.Get(0), "b→a should flip back")
38+
39+
s.SetRange(0, 1, tsDefault)
40+
assert.Equal(t, tsDefault, s.Get(0), "→default must clear")
41+
bmA, bmB = s.Export()
42+
assert.False(t, bmA.Contains(0))
43+
assert.False(t, bmB.Contains(0))
44+
45+
s.SetRange(0, 1, tsA)
46+
s.SetRange(0, 1, tsA)
47+
assert.Equal(t, tsA, s.Get(0), "a→a is idempotent")
48+
})
49+
50+
t.Run("partial overlap moves only the overlapping pages", func(t *testing.T) {
51+
t.Parallel()
52+
s := NewStateTracker(tsDefault, tsA, tsB)
53+
54+
s.SetRange(0, 10, tsA)
55+
s.SetRange(3, 7, tsB)
56+
57+
for i := range uint32(3) {
58+
assert.Equal(t, tsA, s.Get(i), "page %d outside overlap stays a", i)
59+
}
60+
for i := range uint32(4) {
61+
page := i + 3
62+
assert.Equal(t, tsB, s.Get(page), "page %d in overlap moves to b", page)
63+
}
64+
for i := range uint32(3) {
65+
page := i + 7
66+
assert.Equal(t, tsA, s.Get(page), "page %d outside overlap stays a", page)
67+
}
68+
})
69+
70+
t.Run("empty and inverted ranges are no-ops", func(t *testing.T) {
71+
t.Parallel()
72+
s := NewStateTracker(tsDefault, tsA, tsB)
73+
74+
s.SetRange(5, 5, tsA)
75+
s.SetRange(7, 3, tsB)
76+
bmA, bmB := s.Export()
77+
assert.True(t, bmA.IsEmpty())
78+
assert.True(t, bmB.IsEmpty())
79+
})
80+
81+
t.Run("NewStateTracker rejects non-distinct states", func(t *testing.T) {
82+
t.Parallel()
83+
assert.Panics(t, func() { NewStateTracker(tsA, tsA, tsB) }, "default == a must panic")
84+
assert.Panics(t, func() { NewStateTracker(tsA, tsB, tsB) }, "a == b must panic")
85+
assert.Panics(t, func() { NewStateTracker(tsA, tsB, tsA) }, "default == b must panic")
86+
assert.Panics(t, func() { NewStateTracker(tsA, tsA, tsA) }, "all-equal must panic")
87+
})
88+
89+
t.Run("SetRange panics on unsupported state", func(t *testing.T) {
90+
t.Parallel()
91+
s := NewStateTracker(tsDefault, tsA, tsB)
92+
assert.Panics(t, func() { s.SetRange(0, 1, ts(99)) },
93+
"unregistered state value must panic, not silently no-op")
94+
})
95+
}

0 commit comments

Comments
 (0)