Skip to content

Commit

Permalink
feat(redis): tendisplus离线数据导入 #6596
Browse files Browse the repository at this point in the history
  • Loading branch information
lukemakeit committed Sep 19, 2024
1 parent 7eaf1f6 commit 406eb01
Show file tree
Hide file tree
Showing 60 changed files with 4,145 additions and 137 deletions.
5 changes: 5 additions & 0 deletions dbm-services/redis/db-tools/dbactuator/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ require (

require (
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand All @@ -38,11 +40,14 @@ require (
github.com/leodido/go-urn v1.2.3 // indirect
github.com/lufia/plan9stats v0.0.0-20230110061619-bbe2e5e100de // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mozillazg/go-httpheader v0.4.0 // indirect
github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.8.0 // indirect
github.com/smartystreets/assertions v1.2.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tencentyun/cos-go-sdk-v5 v0.7.54 // indirect
github.com/tklauser/go-sysconf v0.3.11 // indirect
github.com/tklauser/numcpus v0.6.0 // indirect
github.com/yusufpapurcu/wmi v1.2.3 // indirect
Expand Down
21 changes: 21 additions & 0 deletions dbm-services/redis/db-tools/dbactuator/go.sum
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
Expand All @@ -31,15 +35,21 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/grafov/m3u8 v0.12.0/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
Expand All @@ -62,6 +72,12 @@ github.com/lufia/plan9stats v0.0.0-20230110061619-bbe2e5e100de h1:V53FWzU6KAZVi1
github.com/lufia/plan9stats v0.0.0-20230110061619-bbe2e5e100de/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
github.com/mozillazg/go-httpheader v0.4.0 h1:aBn6aRXtFzyDLZ4VIRLsZbbJloagQfMnCiYgOq6hK4w=
github.com/mozillazg/go-httpheader v0.4.0/go.mod h1:PuT8h0pw6efvp8ZeUec1Rs7dwjK08bt6gKSReGMqtdA=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
Expand Down Expand Up @@ -100,12 +116,17 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
github.com/tencentyun/cos-go-sdk-v5 v0.7.54 h1:FRamEhNBbSeggyYfWfzFejTLftgbICocSYFk4PKTSV4=
github.com/tencentyun/cos-go-sdk-v5 v0.7.54/go.mod h1:UN+VdbCl1hg+kKi5RXqZgaP+Boqfmk+D04GRc4XFk70=
github.com/tklauser/go-sysconf v0.3.11 h1:89WgdJhk5SNwJfu+GKyYveZ4IaJ7xAkecBo+KdJV0CM=
github.com/tklauser/go-sysconf v0.3.11/go.mod h1:GqXfhXY3kiPa0nAXPDIQIWzJbMCB7AmcWpGR8lSZfqI=
github.com/tklauser/numcpus v0.6.0 h1:kebhY2Qt+3U6RNK7UqpYNA+tJ23IBEGKkB7JQBfDYms=
Expand Down
57 changes: 57 additions & 0 deletions dbm-services/redis/db-tools/dbactuator/models/myredis/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2176,3 +2176,60 @@ func (db *RedisClient) TailRedisLogFile(tailNLine int) (data string, err error)
}
return string(dataBytes), nil
}

// IsReshapeRunning 判断tendisplus/tendisssd是否正在执行reshape
func (db *RedisClient) IsReshapeRunning() (ret bool, err error) {
compactInfo, err := db.Info("Compaction")
if err != nil {
return false, err
}
running := compactInfo["current-compaction-status"]
if running == "running" {
return true, nil
}
return false, nil
}

// WaitTendisReshapeDone 等待tendisplus/tendisssd reshape完成
func (db *RedisClient) WaitTendisReshapeDone() (err error) {
var msg string
count := 0
for {
isReshaping, err := db.IsReshapeRunning()
if err != nil {
return err
}
if !isReshaping {
msg = fmt.Sprintf("redis:%s reshape done", db.Addr)
mylog.Logger.Info(msg)
return nil
}
count++
if (count % 12) == 0 {
msg = fmt.Sprintf("redis:%s reshape is running", db.Addr)
mylog.Logger.Info(msg)
}
time.Sleep(5 * time.Second)
}
}

// TendisReshapeAndWaitDone tendisplus/tendisssd reshape并等待reshape完成
func (db *RedisClient) TendisReshapeAndWaitDone() (err error) {
if db.InstanceClient == nil {
err := fmt.Errorf("reshape redis:%s must create a standalone client", db.Addr)
mylog.Logger.Error(err.Error())
return err
}
isReshaping, err := db.IsReshapeRunning()
if err != nil {
return err
}
if isReshaping {
// 如果正在reshape,则等待reshape完成,不重复执行reshape
return db.WaitTendisReshapeDone()
}
cmd := []interface{}{"reshape"}
// reshape 是阻塞操作,可能会超时,所以不捕获错误
db.InstanceClient.Do(context.TODO(), cmd...).Result()
return db.WaitTendisReshapeDone()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
package atomredis

import (
"encoding/json"
"fmt"
"strconv"
"sync"
"time"

"github.com/go-playground/validator/v10"

"dbm-services/redis/db-tools/dbactuator/models/myredis"
"dbm-services/redis/db-tools/dbactuator/pkg/consts"
"dbm-services/redis/db-tools/dbactuator/pkg/jobruntime"
)

// ClusterResetFlushMeetItem cluster reset flush meet item
type ClusterResetFlushMeetItem struct {
ResetIP string `json:"reset_ip" validate:"required"`
ResetPort int `json:"reset_port" validate:"required"`
ResetRedisPassword string `json:"reset_redis_password" validate:"required"`
MeetIP string `json:"meet_ip" validate:"required"`
MeetPort int `json:"meet_port" validate:"required"`
DoFlushall bool `json:"do_flushall"` // 是否执行flushall
DoClusterMeet bool `json:"do_cluster_meet"` // 是否执行cluster meet
}

// ResetRedisAddr reset redis addr
func (item *ClusterResetFlushMeetItem) ResetRedisAddr() string {
return fmt.Sprintf("%s:%d", item.ResetIP, item.ResetPort)
}

// ClusterResetFlushMeetParams 参数
type ClusterResetFlushMeetParams struct {
ResetFlushMeetParams []ClusterResetFlushMeetItem `json:"reset_flush_meet_params" validate:"required"`
}

// ClusterResetFlushMeet TODO
type ClusterResetFlushMeet struct {
runtime *jobruntime.JobGenericRuntime
params ClusterResetFlushMeetParams
tasks []*clusterResetFlushMeetTask
}

// 无实际作用,仅确保实现了 jobruntime.JobRunner 接口
var _ jobruntime.JobRunner = (*ClusterResetFlushMeet)(nil)

// NewClusterResetFlushMeet new
func NewClusterResetFlushMeet() jobruntime.JobRunner {
return &ClusterResetFlushMeet{}
}

// Init 初始化,参数校验
func (job *ClusterResetFlushMeet) Init(m *jobruntime.JobGenericRuntime) error {
job.runtime = m

err := json.Unmarshal([]byte(job.runtime.PayloadDecoded), &job.params)
if err != nil {
job.runtime.Logger.Error(fmt.Sprintf("json.Unmarshal failed,err:%+v\n", err))
return err
}
// 参数有效性检查
validate := validator.New()
err = validate.Struct(job.params)
if err != nil {
if _, ok := err.(*validator.InvalidValidationError); ok {
job.runtime.Logger.Error("ClusterResetFlushMeet Init params validate failed,err:%v,params:%+v", err, job.params)
return err
}
for _, err := range err.(validator.ValidationErrors) {
job.runtime.Logger.Error("ClusterResetFlushMeet Init params validate failed,err:%v,params:%+v", err, job.params)
return err
}
}
return nil
}

// Name 名字
func (job *ClusterResetFlushMeet) Name() string {
return "redis_cluster_reset_flush_meet"
}

// Run 执行
func (job *ClusterResetFlushMeet) Run() (err error) {
job.tasks = make([]*clusterResetFlushMeetTask, 0, len(job.params.ResetFlushMeetParams))
for _, item := range job.params.ResetFlushMeetParams {
task := &clusterResetFlushMeetTask{
ClusterResetFlushMeetItem: item,
runtime: job.runtime,
}
job.tasks = append(job.tasks, task)
}
err = job.allInstCconnect()
if err != nil {
return err
}
defer job.allInstDisconnect()

for _, tmp := range job.tasks {
task := tmp
task.resetAndFlushallAndMeet()
if task.Err != nil {
return task.Err
}
}
return nil
}

func (job *ClusterResetFlushMeet) allInstCconnect() (err error) {
wg := sync.WaitGroup{}
// 并发确认所有实例是否可连接
for _, tmp := range job.tasks {
task := tmp
wg.Add(1)
go func(task *clusterResetFlushMeetTask) {
defer wg.Done()
task.createResetConn()
}(task)
}
wg.Wait()
for _, tmp := range job.tasks {
task := tmp
if task.Err != nil {
return task.Err
}
}
return nil
}

// allInstDisconnect 所有实例断开连接
func (job *ClusterResetFlushMeet) allInstDisconnect() {
for _, tmp := range job.tasks {
task := tmp
if task.resetRedisConn != nil {
task.resetRedisConn.Close()
task.resetRedisConn = nil
}
}
}

// Retry 返回可重试次数
func (job *ClusterResetFlushMeet) Retry() uint {
return 2
}

// Rollback 回滚函数,一般不用实现
func (job *ClusterResetFlushMeet) Rollback() error {
return nil
}

// clusterResetFlushMeetTask task,为了做并发连接,单独定义一个struct
type clusterResetFlushMeetTask struct {
ClusterResetFlushMeetItem
resetRedisConn *myredis.RedisClient
runtime *jobruntime.JobGenericRuntime
Err error
}

// createResetConn 创建连接
func (task *clusterResetFlushMeetTask) createResetConn() {
task.resetRedisConn, task.Err = myredis.NewRedisClientWithTimeout(task.ResetRedisAddr(), task.ResetRedisPassword, 0,
consts.TendisTypeRedisInstance, 10*time.Hour)
}

// resetAndFlushallAndMeet cluster reset并flushall并meet
func (task *clusterResetFlushMeetTask) resetAndFlushallAndMeet() {
var role string
var clustreInfo *myredis.CmdClusterInfo
var addrToNodes map[string]*myredis.ClusterNodeData
// 先执行cluster reset
task.runtime.Logger.Info(fmt.Sprintf("redis %s cluster reset start", task.ResetRedisAddr()))
task.Err = task.resetRedisConn.ClusterReset()
if task.Err != nil {
return
}
for {
role, _ = task.resetRedisConn.GetRole()
clustreInfo, _ = task.resetRedisConn.ClusterInfo()
if role == consts.RedisMasterRole && clustreInfo.ClusterState != consts.ClusterStateOK {
task.runtime.Logger.Info(fmt.Sprintf("redis %s cluster reset success,current_role:%s cluster_state:%s",
task.ResetRedisAddr(), role, clustreInfo.ClusterState))
break
}
task.runtime.Logger.Info(fmt.Sprintf("redis %s cluster reset done,but current_role:%s cluster_state:%s",
task.ResetRedisAddr(), role, clustreInfo.ClusterState))
time.Sleep(3 * time.Second)
}
if task.DoFlushall {
// 执行flushall
task.runtime.Logger.Info(fmt.Sprintf("redis %s flushall start", task.ResetRedisAddr()))
cmd := []string{consts.TendisPlusFlushAllRename} // cache 和 tendisplus的 flushall 命令一样
_, task.Err = task.resetRedisConn.DoCommand(cmd, 0)
if task.Err != nil {
return
}
}
if task.DoClusterMeet {
// 执行cluster meet
task.runtime.Logger.Info(fmt.Sprintf("redis %s 'cluster meet %s %d' start",
task.ResetRedisAddr(), task.MeetIP, task.MeetPort))
_, task.Err = task.resetRedisConn.ClusterMeet(task.MeetIP, strconv.Itoa(task.MeetPort))
if task.Err != nil {
return
}
for {
addrToNodes, task.Err = task.resetRedisConn.GetAddrMapToNodes()
if task.Err != nil {
return
}
if _, ok := addrToNodes[task.ResetRedisAddr()]; ok {
task.runtime.Logger.Info(fmt.Sprintf("redis %s 'cluster meet %s %d' success",
task.ResetRedisAddr(), task.MeetIP, task.MeetPort))
break
}
task.runtime.Logger.Info(fmt.Sprintf("redis %s 'cluster meet %s %d' done,but not in 'cluster nodes'",
task.ResetRedisAddr(), task.MeetIP, task.MeetPort))
time.Sleep(3 * time.Second)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ func (job *RedisConfigSet) allInstsAbleToConnect() (err error) {
}
job.AddrMapConfigFile[addr] = confFile
// 获取密码
if job.params.Role == consts.MetaRolePredixy || job.params.Role == consts.MetaRoleTwemproxy {
if job.params.Role == consts.MetaRolePredixy {
password, err = myredis.GetPredixyAdminPasswdFromConfFlie(port)
} else if job.params.Role == consts.MetaRoleTwemproxy {
password, err = myredis.GetProxyPasswdFromConfFlie(port, job.params.Role)
} else {
password, err = myredis.GetRedisPasswdFromConfFile(port)
Expand Down
Loading

0 comments on commit 406eb01

Please sign in to comment.