Skip to content

Commit

Permalink
chore: Display token information in partition ring status page (#631)
Browse files Browse the repository at this point in the history
* Show tokens in partition ring status page

* Show ownership column

* Additional countTokens tests

* Add changelog

* Rename header from Instance to Partition in show tokens mode
  • Loading branch information
alexweav authored Jan 3, 2025
1 parent 9935aca commit b54518d
Show file tree
Hide file tree
Showing 6 changed files with 193 additions and 25 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
* [FEATURE] Add methods `Increment`, `FlushAll`, `CompareAndSwap`, `Touch` to `cache.MemcachedClient` #477
* [FEATURE] Add `concurrency.ForEachJobMergeResults()` utility function. #486
* [FEATURE] Add `ring.DoMultiUntilQuorumWithoutSuccessfulContextCancellation()`. #495
* [ENHANCEMENT] Display token information in partition ring status page #631
* [ENHANCEMENT] Add ability to log all source hosts from http header instead of only the first one. #444
* [ENHANCEMENT] Add configuration to customize backoff for the gRPC clients.
* [ENHANCEMENT] Use `SecretReader` interface to fetch secrets when configuring TLS. #274
Expand Down
23 changes: 23 additions & 0 deletions ring/partition_ring_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
_ "embed"
"fmt"
"html/template"
"math"
"net/http"
"slices"
"sort"
Expand All @@ -18,6 +19,9 @@ var partitionRingPageTemplate = template.Must(template.New("webpage").Funcs(temp
"mod": func(i, j int32) bool {
return i%j == 0
},
"humanFloat": func(f float64) string {
return fmt.Sprintf("%.3g", f)
},
"formatTimestamp": func(ts time.Time) string {
return ts.Format("2006-01-02 15:04:05 MST")
},
Expand Down Expand Up @@ -55,6 +59,7 @@ func (h *PartitionRingPageHandler) handleGetRequest(w http.ResponseWriter, req *
ring = h.reader.PartitionRing()
ringDesc = ring.desc
)
ownedTokens := ringDesc.countTokens()

// Prepare the data to render partitions in the page.
partitionsByID := make(map[int32]partitionPageData, len(ringDesc.Partitions))
Expand All @@ -68,6 +73,9 @@ func (h *PartitionRingPageHandler) handleGetRequest(w http.ResponseWriter, req *
State: partition.State,
StateTimestamp: partition.GetStateTime(),
OwnerIDs: owners,
Tokens: partition.Tokens,
NumTokens: len(partition.Tokens),
Ownership: distancePercentage(ownedTokens[id]),
}
}

Expand All @@ -83,6 +91,9 @@ func (h *PartitionRingPageHandler) handleGetRequest(w http.ResponseWriter, req *
State: PartitionUnknown,
StateTimestamp: time.Time{},
OwnerIDs: []string{ownerID},
Tokens: partition.Tokens,
NumTokens: len(partition.Tokens),
Ownership: distancePercentage(ownedTokens[owner.OwnedPartition]),
}

partitionsByID[owner.OwnedPartition] = partition
Expand All @@ -105,13 +116,16 @@ func (h *PartitionRingPageHandler) handleGetRequest(w http.ResponseWriter, req *
return partitions[i].ID < partitions[j].ID
})

tokensParam := req.URL.Query().Get("tokens")

renderHTTPResponse(w, partitionRingPageData{
Partitions: partitions,
PartitionStateChanges: map[PartitionState]PartitionState{
PartitionPending: PartitionActive,
PartitionActive: PartitionInactive,
PartitionInactive: PartitionActive,
},
ShowTokens: tokensParam == "true",
}, partitionRingPageTemplate, req)
}

Expand Down Expand Up @@ -146,6 +160,7 @@ type partitionRingPageData struct {

// PartitionStateChanges maps the allowed state changes through the UI.
PartitionStateChanges map[PartitionState]PartitionState `json:"-"`
ShowTokens bool `json:"-"`
}

type partitionPageData struct {
Expand All @@ -154,4 +169,12 @@ type partitionPageData struct {
State PartitionState `json:"state"`
StateTimestamp time.Time `json:"state_timestamp"`
OwnerIDs []string `json:"owner_ids"`
Tokens []uint32 `json:"tokens"`
NumTokens int `json:"-"`
Ownership float64 `json:"-"`
}

// distancePercentage renders a given token distance as the percentage of all possible token values covered by that distance.
func distancePercentage(distance int64) float64 {
return (float64(distance) / float64(math.MaxUint32)) * 100
}
98 changes: 73 additions & 25 deletions ring/partition_ring_http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ func TestPartitionRingPageHandler_ViewPage(t *testing.T) {
1: {
State: PartitionActive,
StateTimestamp: time.Now().Unix(),
Tokens: []uint32{1000000, 3000000, 6000000},
},
2: {
State: PartitionInactive,
StateTimestamp: time.Now().Unix(),
Tokens: []uint32{2000000, 4000000, 5000000, 7000000},
},
},
Owners: map[string]OwnerDesc{
Expand Down Expand Up @@ -59,31 +61,77 @@ func TestPartitionRingPageHandler_ViewPage(t *testing.T) {
)

recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/partition-ring", nil))

assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "text/html", recorder.Header().Get("Content-Type"))

assert.Regexp(t, regexp.MustCompile(fmt.Sprintf("(?m)%s", strings.Join([]string{
"<td>", "1", "</td>",
"<td>", "Active", "</td>",
"<td>", "[^<]+", "</td>",
"<td>", "ingester-zone-a-0", "<br />", "ingester-zone-b-0", "<br />", "</td>",
}, `\s*`))), recorder.Body.String())

assert.Regexp(t, regexp.MustCompile(fmt.Sprintf("(?m)%s", strings.Join([]string{
"<td>", "2", "</td>",
"<td>", "Inactive", "</td>",
"<td>", "[^<]+", "</td>",
"<td>", "ingester-zone-a-1", "<br />", "ingester-zone-b-1", "<br />", "</td>",
}, `\s*`))), recorder.Body.String())

assert.Regexp(t, regexp.MustCompile(fmt.Sprintf("(?m)%s", strings.Join([]string{
"<td>", "3", "</td>",
"<td>", "Corrupt", "</td>",
"<td>", "N/A", "</td>",
"<td>", "ingester-zone-b-2", "<br />", "</td>",
}, `\s*`))), recorder.Body.String())

t.Run("displays expected partition info", func(t *testing.T) {
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/partition-ring", nil))

assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "text/html", recorder.Header().Get("Content-Type"))

assert.Regexp(t, regexp.MustCompile(fmt.Sprintf("(?m)%s", strings.Join([]string{
"<td>", "1", "</td>",
"<td>", "Active", "</td>",
"<td>", "[^<]+", "</td>",
"<td>", "ingester-zone-a-0", "<br />", "ingester-zone-b-0", "<br />", "</td>",
"<td>", "3", "</td>",
"<td>", "99.9%", "</td>",
}, `\s*`))), recorder.Body.String())

assert.Regexp(t, regexp.MustCompile(fmt.Sprintf("(?m)%s", strings.Join([]string{
"<td>", "2", "</td>",
"<td>", "Inactive", "</td>",
"<td>", "[^<]+", "</td>",
"<td>", "ingester-zone-a-1", "<br />", "ingester-zone-b-1", "<br />", "</td>",
"<td>", "4", "</td>",
"<td>", "0.0931%", "</td>",
}, `\s*`))), recorder.Body.String())

assert.Regexp(t, regexp.MustCompile(fmt.Sprintf("(?m)%s", strings.Join([]string{
"<td>", "3", "</td>",
"<td>", "Corrupt", "</td>",
"<td>", "N/A", "</td>",
"<td>", "ingester-zone-b-2", "<br />", "</td>",
"<td>", "0", "</td>",
"<td>", "0%", "</td>",
}, `\s*`))), recorder.Body.String())
})

t.Run("displays Show Tokens button by default", func(t *testing.T) {
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/partition-ring", nil))

assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "text/html", recorder.Header().Get("Content-Type"))

assert.Regexp(t, regexp.MustCompile(fmt.Sprintf("(?m)%s", strings.Join([]string{
`<input type="button" value="Show Tokens" onclick="window.location.href = '\?tokens=true'"/>`,
}, `\s*`))), recorder.Body.String())
})

t.Run("displays tokens when Show Tokens is enabled", func(t *testing.T) {
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/partition-ring?tokens=true", nil))

assert.Equal(t, http.StatusOK, recorder.Code)
assert.Equal(t, "text/html", recorder.Header().Get("Content-Type"))

assert.Regexp(t, regexp.MustCompile(fmt.Sprintf("(?m)%s", strings.Join([]string{
`<input type="button" value="Hide Tokens" onclick="window.location.href = '\?tokens=false'"/>`,
}, `\s*`))), recorder.Body.String())

assert.Regexp(t, regexp.MustCompile(fmt.Sprintf("(?m)%s", strings.Join([]string{
"<h2>", "Partition: 1", "</h2>",
"<p>", "Tokens:<br/>", "1000000", "3000000", "6000000", "</p>",
}, `\s*`))), recorder.Body.String())

assert.Regexp(t, regexp.MustCompile(fmt.Sprintf("(?m)%s", strings.Join([]string{
"<h2>", "Partition: 2", "</h2>",
"<p>", "Tokens:<br/>", "2000000", "4000000", "5000000", "7000000", "</p>",
}, `\s*`))), recorder.Body.String())

assert.Regexp(t, regexp.MustCompile(fmt.Sprintf("(?m)%s", strings.Join([]string{
"<h2>", "Partition: 3", "</h2>",
"<p>", "Tokens:<br/>", "</p>",
}, `\s*`))), recorder.Body.String())
})
}

func TestPartitionRingPageHandler_ChangePartitionState(t *testing.T) {
Expand Down
28 changes: 28 additions & 0 deletions ring/partition_ring_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,34 @@ func (m *PartitionRingDesc) partitionByToken() map[Token]int32 {
return out
}

// CountTokens returns the summed token distance of all tokens in each partition.
func (m *PartitionRingDesc) countTokens() map[int32]int64 {
owned := make(map[int32]int64, len(m.Partitions))
sortedTokens := m.tokens()
tokensToPartitions := m.partitionByToken()

for i, token := range sortedTokens {
partition := tokensToPartitions[Token(token)]

var prevToken uint32
if i == 0 {
prevToken = sortedTokens[len(sortedTokens)-1]
} else {
prevToken = sortedTokens[i-1]
}
diff := tokenDistance(prevToken, token)
owned[partition] = owned[partition] + diff
}

// Partitions with 0 tokens should still exist in the result.
for id := range m.Partitions {
if _, ok := owned[id]; !ok {
owned[id] = 0
}
}
return owned
}

// ownersByPartition returns a map where the key is the partition ID and the value is a list of owner IDs.
func (m *PartitionRingDesc) ownersByPartition() map[int32][]string {
out := make(map[int32][]string, len(m.Partitions))
Expand Down
46 changes: 46 additions & 0 deletions ring/partition_ring_model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ring

import (
"fmt"
"math"
"testing"
"time"

Expand Down Expand Up @@ -79,6 +80,51 @@ func TestPartitionRingDesc_countPartitionsByState(t *testing.T) {
})
}

func TestPartitionRingDesc_countTokens(t *testing.T) {
t.Run("empty ring should return an empty result", func(t *testing.T) {
desc := &PartitionRingDesc{}

result := desc.countTokens()

assert.Empty(t, result)
})

t.Run("ring with some partitions should return correct distances", func(t *testing.T) {
desc := &PartitionRingDesc{
Partitions: map[int32]PartitionDesc{
1: {Tokens: []uint32{1000000, 3000000, 6000000}},
2: {Tokens: []uint32{2000000, 4000000, 8000000}},
3: {Tokens: []uint32{5000000, 9000000}},
},
}

result := desc.countTokens()

expected := map[int32]int64{
1: 3000000 + (int64(math.MaxUint32) + 1 - 9000000),
2: 4000000,
3: 2000000,
}
assert.Equal(t, expected, result)
})

t.Run("partitions with no tokens should be present in the result, with 0 distance", func(t *testing.T) {
desc := &PartitionRingDesc{
Partitions: map[int32]PartitionDesc{
1: {Tokens: []uint32{1000000, 3000000, 6000000}},
2: {Tokens: []uint32{2000000, 4000000, 8000000}},
3: {Tokens: []uint32{5000000, 9000000}},
4: {Tokens: []uint32{}},
},
}

result := desc.countTokens()

assert.Contains(t, result, int32(4))
assert.Equal(t, int64(0), result[4])
})
}

func TestPartitionRingDesc_AddOrUpdateOwner(t *testing.T) {
now := time.Now()

Expand Down
22 changes: 22 additions & 0 deletions ring/partition_ring_status.gohtml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
<th>State</th>
<th>State updated at</th>
<th>Owners</th>
<th>Tokens</th>
<th>Ownership</th>
<th>Actions</th>
</tr>
</thead>
Expand Down Expand Up @@ -42,6 +44,8 @@
{{$ownerID}} <br />
{{ end }}
</td>
<td>{{ .NumTokens }}</td>
<td>{{ .Ownership | humanFloat }}%</td>
<td>
<!-- Allow to force a state change -->
{{ if and (not .Corrupted) (ne (index $stateChanges .State) 0) }}
Expand All @@ -59,5 +63,23 @@
{{ end }}
</tbody>
</table>
<br>
{{ if .ShowTokens }}
<input type="button" value="Hide Tokens" onclick="window.location.href = '?tokens=false'"/>
{{ else }}
<input type="button" value="Show Tokens" onclick="window.location.href = '?tokens=true'"/>
{{ end }}

{{ if .ShowTokens }}
{{ range $i, $partition := .Partitions }}
<h2>Partition: {{ .ID }}</h2>
<p>
Tokens:<br/>
{{ range $token := .Tokens }}
{{ $token }}
{{ end }}
</p>
{{ end }}
{{ end }}
</body>
</html>

0 comments on commit b54518d

Please sign in to comment.