blob: d9e14705f6b9295c3b475dc22d7b647c98e2c2c8 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*
*/
package api
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/require"
"github.com/apache/kvrocks-controller/config"
"github.com/apache/kvrocks-controller/consts"
"github.com/apache/kvrocks-controller/controller"
"github.com/apache/kvrocks-controller/server/middleware"
"github.com/apache/kvrocks-controller/store"
"github.com/apache/kvrocks-controller/store/engine"
"github.com/apache/kvrocks-controller/util"
)
func TestClusterBasics(t *testing.T) {
ns := "test-ns"
handler := &ClusterHandler{s: store.NewClusterStore(engine.NewMock())}
runCreate := func(t *testing.T, name string, expectedStatusCode int) {
testCreateRequest := &CreateClusterRequest{
Name: name,
Nodes: []string{"127.0.0.1:1234", "127.0.0.1:1235", "127.0.0.1:1236", "127.0.0.1:1237"},
Replicas: 2,
}
recorder := httptest.NewRecorder()
ctx := GetTestContext(recorder)
body, err := json.Marshal(testCreateRequest)
require.NoError(t, err)
ctx.Header(consts.HeaderDontCheckClusterMode, "yes")
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
ctx.Params = []gin.Param{{Key: "namespace", Value: ns}}
handler.Create(ctx)
require.Equal(t, expectedStatusCode, recorder.Code)
}
runGet := func(t *testing.T, name string, expectedStatusCode int) {
recorder := httptest.NewRecorder()
ctx := GetTestContext(recorder)
ctx.Set(consts.ContextKeyStore, handler.s)
ctx.Params = []gin.Param{{Key: "namespace", Value: ns}, {Key: "cluster", Value: name}}
middleware.RequiredCluster(ctx)
if recorder.Code != http.StatusOK {
return
}
handler.Get(ctx)
require.Equal(t, expectedStatusCode, recorder.Code)
}
runRemove := func(t *testing.T, name string, expectedStatusCode int) {
recorder := httptest.NewRecorder()
ctx := GetTestContext(recorder)
ctx.Set(consts.ContextKeyStore, handler.s)
ctx.Params = []gin.Param{{Key: "namespace", Value: ns}, {Key: "cluster", Value: name}}
middleware.RequiredCluster(ctx)
if recorder.Code != http.StatusOK {
return
}
handler.Remove(ctx)
require.Equal(t, expectedStatusCode, recorder.Code)
}
t.Run("create cluster", func(t *testing.T) {
runCreate(t, "test-cluster", http.StatusCreated)
runCreate(t, "test-cluster", http.StatusConflict)
})
t.Run("get cluster", func(t *testing.T) {
runGet(t, "test-cluster", http.StatusOK)
runGet(t, "not-exist", http.StatusNotFound)
})
t.Run("list cluster", func(t *testing.T) {
recorder := httptest.NewRecorder()
ctx := GetTestContext(recorder)
ctx.Set(consts.ContextKeyStore, handler.s)
ctx.Params = []gin.Param{{Key: "namespace", Value: ns}}
handler.List(ctx)
require.Equal(t, http.StatusOK, recorder.Code)
var rsp struct {
Data struct {
Clusters []string `json:"clusters"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &rsp))
require.ElementsMatch(t, []string{"test-cluster"}, rsp.Data.Clusters)
})
t.Run("migrate slot only", func(t *testing.T) {
handler := &ClusterHandler{s: store.NewClusterStore(engine.NewMock())}
clusterName := "test-migrate-slot-only-cluster"
recorder := httptest.NewRecorder()
ctx := GetTestContext(recorder)
ctx.Set(consts.ContextKeyStore, handler.s)
ctx.Params = []gin.Param{{Key: "namespace", Value: ns}, {Key: "cluster", Value: clusterName}}
slotRange, err := store.NewSlotRange(3, 3)
require.NoError(t, err)
testMigrateReq := &MigrateSlotRequest{
Slot: slotRange,
SlotOnly: true,
Target: 1,
}
body, err := json.Marshal(testMigrateReq)
require.NoError(t, err)
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
cluster, err := store.NewCluster(clusterName, []string{"127.0.0.1:1111", "127.0.0.1:2222"}, 1)
require.NoError(t, err)
require.NoError(t, handler.s.CreateCluster(ctx, ns, cluster))
before, err := handler.s.GetCluster(ctx, ns, clusterName)
require.NoError(t, err)
require.EqualValues(t, store.SlotRange{Start: 0, Stop: 8191}, before.Shards[0].SlotRanges[0])
require.EqualValues(t, store.SlotRange{Start: 8192, Stop: store.MaxSlotID}, before.Shards[1].SlotRanges[0])
middleware.RequiredCluster(ctx)
handler.MigrateSlot(ctx)
require.Equal(t, http.StatusOK, recorder.Code)
after, err := handler.s.GetCluster(ctx, ns, clusterName)
require.NoError(t, err)
require.EqualValues(t, before.Version.Add(1), after.Version.Load())
require.Len(t, after.Shards[0].SlotRanges, 2)
require.EqualValues(t, store.SlotRange{Start: 0, Stop: 2}, after.Shards[0].SlotRanges[0])
require.EqualValues(t, store.SlotRange{Start: 4, Stop: 8191}, after.Shards[0].SlotRanges[1])
require.Len(t, after.Shards[1].SlotRanges, 2)
require.EqualValues(t, store.SlotRange{Start: 3, Stop: 3}, after.Shards[1].SlotRanges[0])
require.EqualValues(t, store.SlotRange{Start: 8192, Stop: store.MaxSlotID}, after.Shards[1].SlotRanges[1])
})
t.Run("remove cluster", func(t *testing.T) {
runRemove(t, "test-cluster", http.StatusNoContent)
runRemove(t, "not-exist", http.StatusNotFound)
})
}
func TestClusterImport(t *testing.T) {
ns := "test-ns"
clusterName := "test-cluster-import"
handler := &ClusterHandler{s: store.NewClusterStore(engine.NewMock())}
// cluster import must be done on a real cluster
testNodeAddr := "127.0.0.1:7770"
clusterNode := store.NewClusterNode(testNodeAddr, "")
cluster, err := store.NewCluster(clusterName, []string{testNodeAddr}, 1)
require.NoError(t, err)
ctx := context.Background()
require.NoError(t, cluster.Reset(ctx))
require.NoError(t, clusterNode.SyncClusterInfo(ctx, cluster))
defer func() {
// clean up the cluster information to avoid affecting other tests
require.NoError(t, cluster.Reset(ctx))
}()
var req struct {
Nodes []string `json:"nodes"`
}
req.Nodes = []string{testNodeAddr}
recorder := httptest.NewRecorder()
testCtx := GetTestContext(recorder)
body, err := json.Marshal(req)
require.NoError(t, err)
testCtx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
testCtx.Params = []gin.Param{{Key: "namespace", Value: ns}, {Key: "cluster", Value: "test-cluster-import"}}
handler.Import(testCtx)
require.Equal(t, http.StatusOK, recorder.Code)
var rsp struct {
Data struct {
Cluster *store.Cluster `json:"cluster"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &rsp))
require.Len(t, rsp.Data.Cluster.Shards, 1)
require.Len(t, rsp.Data.Cluster.Shards[0].Nodes, 1)
require.Equal(t, testNodeAddr, rsp.Data.Cluster.Shards[0].Nodes[0].Addr())
}
func TestClusterMigrateData(t *testing.T) {
ns := "test-ns"
clusterName := "test-cluster"
clusterStore := store.NewClusterStore(engine.NewMock())
handler := &ClusterHandler{s: clusterStore}
ctx := context.Background()
nodeAddrs := []string{"127.0.0.1:7770", "127.0.0.1:7771"}
sourceRedisClient := redis.NewClient(&redis.Options{Addr: nodeAddrs[0]})
targetRedisClient := redis.NewClient(&redis.Options{Addr: nodeAddrs[1]})
cluster, err := store.NewCluster(clusterName, nodeAddrs, 1)
require.NoError(t, err)
require.NoError(t, cluster.Reset(ctx))
defer func() {
require.NoError(t, cluster.Reset(ctx))
}()
require.NoError(t, cluster.SyncToNodes(ctx))
clusterStore.CreateCluster(ctx, ns, cluster)
sendRequest := func(t *testing.T, ns, cluster string, slotRange store.SlotRange) {
recorder := httptest.NewRecorder()
reqCtx := GetTestContext(recorder)
reqCtx.Set(consts.ContextKeyStore, handler.s)
reqCtx.Params = []gin.Param{{Key: "namespace", Value: ns}, {Key: "cluster", Value: cluster}}
body, err := json.Marshal(&MigrateSlotRequest{Target: 1, Slot: slotRange})
require.NoError(t, err)
reqCtx.Request.Body = io.NopCloser(bytes.NewBuffer(body))
middleware.RequiredCluster(reqCtx)
handler.MigrateSlot(reqCtx)
require.Equal(t, http.StatusOK, recorder.Code)
}
runController := func(t *testing.T) *controller.Controller {
ctrl, err := controller.New(clusterStore, &config.ControllerConfig{
FailOver: &config.FailOverConfig{
PingIntervalSeconds: 1,
MaxPingCount: 3,
},
})
require.NoError(t, err)
require.NoError(t, ctrl.Start(ctx))
ctrl.WaitForReady()
return ctrl
}
t.Run("migrate slot(s) from one shard to another", func(t *testing.T) {
for _, slotRange := range []store.SlotRange{
{Start: 10, Stop: 10},
{Start: 11, Stop: 14},
} {
for i := slotRange.Start; i <= slotRange.Stop; i++ {
require.NoError(t, sourceRedisClient.Set(ctx, util.SlotTable[i], "test-value", 0).Err())
}
sendRequest(t, ns, "test-cluster", slotRange)
gotCluster, err := clusterStore.GetCluster(ctx, ns, clusterName)
require.NoError(t, err)
currentVersion := gotCluster.Version.Load()
sourceSlotRanges := gotCluster.Shards[0].SlotRanges
targetSlotRanges := gotCluster.Shards[1].SlotRanges
require.EqualValues(t, slotRange, gotCluster.Shards[0].MigratingSlot.SlotRange)
require.EqualValues(t, 1, gotCluster.Shards[0].TargetShardIndex)
// Run the controller to check and update the migration status
controller := runController(t)
require.Eventually(t, func() bool {
gotCluster, err := handler.s.GetCluster(ctx, ns, "test-cluster")
require.NoError(t, err)
return gotCluster.Shards[0].MigratingSlot == nil
}, 10*time.Second, 100*time.Millisecond)
controller.Close()
// Check if the slot range has been removed from the source shard and added to the target shard
gotCluster, err = clusterStore.GetCluster(ctx, ns, clusterName)
require.NoError(t, err)
require.EqualValues(t, currentVersion+1, gotCluster.Version.Load())
require.Nil(t, gotCluster.Shards[0].MigratingSlot)
require.EqualValues(t, -1, gotCluster.Shards[0].TargetShardIndex)
require.EqualValues(t, store.RemoveSlotFromSlotRanges(sourceSlotRanges, slotRange), gotCluster.Shards[0].SlotRanges)
require.EqualValues(t, store.AddSlotToSlotRanges(targetSlotRanges, slotRange), gotCluster.Shards[1].SlotRanges)
for i := slotRange.Start; i <= slotRange.Stop; i++ {
val, err := targetRedisClient.Get(ctx, util.SlotTable[i]).Result()
require.NoError(t, err)
require.EqualValues(t, "test-value", val)
}
}
})
}