parent
d55d416f68
commit
a2a28b43c5
@ -1,246 +0,0 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/dtm-labs/rockscache"
|
||||
"github.com/google/uuid"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/cache/cachekey"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/database"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/msgprocessor"
|
||||
"github.com/openimsdk/tools/errs"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errLock = errors.New("lock failed")
|
||||
|
||||
type MallocSeq interface {
|
||||
Malloc(ctx context.Context, conversationID string, size int64) ([]int64, error)
|
||||
GetMaxSeq(ctx context.Context, conversationID string) (int64, error)
|
||||
GetMinSeq(ctx context.Context, conversationID string) (int64, error)
|
||||
SetMinSeq(ctx context.Context, conversationID string, seq int64) error
|
||||
}
|
||||
|
||||
func NewSeqCache1(rdb redis.UniversalClient, mgo database.Seq) MallocSeq {
|
||||
opt := rockscache.NewDefaultOptions()
|
||||
opt.EmptyExpire = time.Second * 3
|
||||
opt.Delay = time.Second / 2
|
||||
return &seqCache1{
|
||||
rdb: rdb,
|
||||
mgo: mgo,
|
||||
rocks: rockscache.NewClient(rdb, opt),
|
||||
lockExpire: time.Minute * 1,
|
||||
seqExpire: time.Hour * 24 * 7,
|
||||
minSeqExpire: time.Hour * 1,
|
||||
groupMinNum: 1000,
|
||||
userMinNum: 100,
|
||||
}
|
||||
}
|
||||
|
||||
type seqCache1 struct {
|
||||
rdb redis.UniversalClient
|
||||
rocks *rockscache.Client
|
||||
mgo database.Seq
|
||||
lockExpire time.Duration
|
||||
seqExpire time.Duration
|
||||
minSeqExpire time.Duration
|
||||
groupMinNum int64
|
||||
userMinNum int64
|
||||
}
|
||||
|
||||
/*
|
||||
1
|
||||
2
|
||||
3
|
||||
4
|
||||
5
|
||||
6
|
||||
7
|
||||
8
|
||||
9
|
||||
10
|
||||
*/
|
||||
|
||||
func (s *seqCache1) GetMaxSeq(ctx context.Context, conversationID string) (int64, error) {
|
||||
for i := 0; i < 10; i++ {
|
||||
res, err := s.rdb.LIndex(ctx, cachekey.GetMallocSeqKey(conversationID), 0).Int64()
|
||||
if err == nil {
|
||||
return res, nil
|
||||
} else if !errors.Is(err, redis.Nil) {
|
||||
return 0, errs.Wrap(err)
|
||||
}
|
||||
|
||||
if err := s.mallocSeq(ctx, conversationID, 0, nil); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return 0, errs.New("get max seq timeout")
|
||||
}
|
||||
|
||||
func (s *seqCache1) unlock(ctx context.Context, key string, owner string) error {
|
||||
script := `
|
||||
local value = redis.call("GET", KEYS[1])
|
||||
if value == false then
|
||||
return 0
|
||||
end
|
||||
if value == ARGV[1] then
|
||||
redis.call("DEL", KEYS[1])
|
||||
return 1
|
||||
end
|
||||
return 2
|
||||
`
|
||||
state, err := s.rdb.Eval(ctx, script, []string{key}, owner).Int()
|
||||
if err != nil {
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
switch state {
|
||||
case 0:
|
||||
return errs.Wrap(redis.Nil)
|
||||
case 1:
|
||||
return nil
|
||||
case 2:
|
||||
return errs.New("not the lock holder")
|
||||
default:
|
||||
return errs.New(fmt.Sprintf("unknown state: %d", state))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *seqCache1) initMallocSeq(ctx context.Context, conversationID string, size int64) ([]int64, error) {
|
||||
owner := uuid.New().String()
|
||||
ok, err := s.rdb.SetNX(ctx, cachekey.GetMallocSeqLockKey(conversationID), owner, s.lockExpire).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seq, err := s.mgo.Malloc(ctx, conversationID, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seqs := make([]int64, 0, size)
|
||||
for i := seq - size + 1; i <= seq; i++ {
|
||||
seqs = append(seqs, i)
|
||||
}
|
||||
return seqs, nil
|
||||
}
|
||||
|
||||
func (s *seqCache1) GetMinSeq(ctx context.Context, conversationID string) (int64, error) {
|
||||
return getCache[int64](ctx, s.rocks, cachekey.GetMallocMinSeqKey(conversationID), s.minSeqExpire, func(ctx context.Context) (int64, error) {
|
||||
return s.mgo.GetMinSeq(ctx, conversationID)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *seqCache1) SetMinSeq(ctx context.Context, conversationID string, seq int64) error {
|
||||
if err := s.mgo.SetMinSeq(ctx, conversationID, seq); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deleteMinSeqCache(ctx, conversationID)
|
||||
}
|
||||
|
||||
func (s *seqCache1) Malloc(ctx context.Context, conversationID string, size int64) ([]int64, error) {
|
||||
if size <= 0 {
|
||||
return nil, errs.Wrap(errors.New("size must be greater than 0"))
|
||||
}
|
||||
seqKey := cachekey.GetMallocSeqKey(conversationID)
|
||||
lockKey := cachekey.GetMallocSeqLockKey(conversationID)
|
||||
for i := 0; i < 10; i++ {
|
||||
seqs, err := s.lpop(ctx, seqKey, lockKey, size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(seqs) < int(size) {
|
||||
if err := s.mallocSeq(ctx, conversationID, size, &seqs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(seqs) >= int(size) {
|
||||
return seqs, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.ErrInternalServer.WrapMsg("malloc seq failed")
|
||||
}
|
||||
|
||||
func (s *seqCache1) push(ctx context.Context, seqKey string, seqs []int64) error {
|
||||
script := `
|
||||
redis.call("DEL", KEYS[1])
|
||||
for i = 2, #ARGV do
|
||||
redis.call("RPUSH", KEYS[1], ARGV[i])
|
||||
end
|
||||
redis.call("EXPIRE", KEYS[1], ARGV[1])
|
||||
return 1
|
||||
`
|
||||
argv := make([]any, 0, 1+len(seqs))
|
||||
argv = append(argv, s.seqExpire.Seconds())
|
||||
for _, seq := range seqs {
|
||||
argv = append(argv, seq)
|
||||
}
|
||||
err := s.rdb.Eval(ctx, script, []string{seqKey}, argv...).Err()
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
func (s *seqCache1) lpop(ctx context.Context, seqKey, lockKey string, size int64) ([]int64, error) {
|
||||
script := `
|
||||
local result = redis.call("LRANGE", KEYS[1], 0, ARGV[1]-1)
|
||||
if #result == 0 then
|
||||
return result
|
||||
end
|
||||
redis.call("LTRIM", KEYS[1], #result, -1)
|
||||
if redis.call("LLEN", KEYS[1]) == 0 then
|
||||
redis.call("DEL", KEYS[2])
|
||||
end
|
||||
return result
|
||||
`
|
||||
res, err := s.rdb.Eval(ctx, script, []string{seqKey, lockKey}, size).Int64Slice()
|
||||
if err != nil {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *seqCache1) getMongoStepSize(conversationID string, size int64) int64 {
|
||||
var num int64
|
||||
if msgprocessor.IsGroupConversationID(conversationID) {
|
||||
num = s.groupMinNum
|
||||
} else {
|
||||
num = s.userMinNum
|
||||
}
|
||||
if size > num {
|
||||
num += size
|
||||
}
|
||||
return num
|
||||
}
|
||||
|
||||
func (s *seqCache1) mallocSeq(ctx context.Context, conversationID string, size int64, seqs *[]int64) error {
|
||||
var delMinSeqKey bool
|
||||
_, err := getCache[string](ctx, s.rocks, cachekey.GetMallocSeqLockKey(conversationID), s.lockExpire, func(ctx context.Context) (string, error) {
|
||||
res, err := s.mgo.Malloc(ctx, conversationID, s.getMongoStepSize(conversationID, size))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
delMinSeqKey = res[0] == 1
|
||||
if seqs != nil && size > 0 {
|
||||
if len(*seqs) > 0 && (*seqs)[len(*seqs)-1]+1 == res[0] {
|
||||
n := size - int64(len(*seqs))
|
||||
*seqs = append(*seqs, res[:n]...)
|
||||
res = res[n:]
|
||||
} else {
|
||||
*seqs = res[:size]
|
||||
res = res[size:]
|
||||
}
|
||||
}
|
||||
if err := s.push(ctx, cachekey.GetMallocSeqKey(conversationID), res); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strconv.Itoa(int(time.Now().UnixMicro())), nil
|
||||
})
|
||||
if delMinSeqKey {
|
||||
s.deleteMinSeqCache(ctx, conversationID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *seqCache1) deleteMinSeqCache(ctx context.Context, conversationID string) error {
|
||||
return s.rocks.TagAsDeleted2(ctx, cachekey.GetMallocMinSeqKey(conversationID))
|
||||
}
|
@ -0,0 +1,109 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/database/mgo"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.mongodb.org/mongo-driver/mongo/options"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestSeq() *seqConversationCacheRedis {
|
||||
mgocli, err := mongo.Connect(context.Background(), options.Client().ApplyURI("mongodb://openIM:openIM123@172.16.8.48:37017/openim_v3?maxPoolSize=100").SetConnectTimeout(5*time.Second))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
model, err := mgo.NewSeqConversationMongo(mgocli.Database("openim_v3"))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
opt := &redis.Options{
|
||||
Addr: "172.16.8.48:16379",
|
||||
Password: "openIM123",
|
||||
DB: 1,
|
||||
}
|
||||
rdb := redis.NewClient(opt)
|
||||
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return NewSeqConversationCacheRedis(rdb, model).(*seqConversationCacheRedis)
|
||||
}
|
||||
|
||||
func TestSeq(t *testing.T) {
|
||||
ts := newTestSeq()
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
speed atomic.Int64
|
||||
)
|
||||
|
||||
const count = 128
|
||||
wg.Add(count)
|
||||
for i := 0; i < count; i++ {
|
||||
index := i + 1
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var size int64 = 10
|
||||
cID := strconv.Itoa(index * 1)
|
||||
for i := 1; ; i++ {
|
||||
//first, err := ts.mgo.Malloc(context.Background(), cID, size) // mongo
|
||||
first, err := ts.Malloc(context.Background(), cID, size) // redis
|
||||
if err != nil {
|
||||
t.Logf("[%d-%d] %s %s", index, i, cID, err)
|
||||
return
|
||||
}
|
||||
speed.Add(size)
|
||||
_ = first
|
||||
//t.Logf("[%d] %d -> %d", i, first+1, first+size)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(time.Second)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
ticker.Stop()
|
||||
return
|
||||
case <-ticker.C:
|
||||
value := speed.Swap(0)
|
||||
t.Logf("speed: %d/s", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDel(t *testing.T) {
|
||||
ts := newTestSeq()
|
||||
for i := 1; i < 100; i++ {
|
||||
var size int64 = 100
|
||||
first, err := ts.Malloc(context.Background(), "100", size)
|
||||
if err != nil {
|
||||
t.Logf("[%d] %s", i, err)
|
||||
return
|
||||
}
|
||||
t.Logf("[%d] %d -> %d", i, first+1, first+size)
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeqMalloc(t *testing.T) {
|
||||
ts := newTestSeq()
|
||||
t.Log(ts.GetMaxSeq(context.Background(), "100"))
|
||||
}
|
||||
|
||||
func TestMinSeq(t *testing.T) {
|
||||
ts := newTestSeq()
|
||||
t.Log(ts.GetMinSeq(context.Background(), "10000000"))
|
||||
}
|
@ -1,77 +0,0 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSeq(t *testing.T) {
|
||||
ts := NewTestSeq()
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
speed atomic.Int64
|
||||
)
|
||||
|
||||
const count = 256
|
||||
wg.Add(count)
|
||||
for i := 0; i < count; i++ {
|
||||
index := i + 1
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
var size int64 = 1
|
||||
cID := strconv.Itoa(index * 100)
|
||||
for i := 1; ; i++ {
|
||||
first, err := ts.mgo.Malloc(context.Background(), cID, size) // mongo
|
||||
//first, err := ts.Malloc(context.Background(), cID, size) // redis
|
||||
if err != nil {
|
||||
t.Logf("[%d-%d] %s %s", index, i, cID, err)
|
||||
return
|
||||
}
|
||||
speed.Add(size)
|
||||
_ = first
|
||||
//t.Logf("[%d] %d -> %d", i, first+1, first+size)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(time.Second)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
ticker.Stop()
|
||||
return
|
||||
case <-ticker.C:
|
||||
value := speed.Swap(0)
|
||||
t.Logf("speed: %d/s", value)
|
||||
}
|
||||
}
|
||||
|
||||
//for i := 1; i < 1000000; i++ {
|
||||
// var size int64 = 100
|
||||
// first, err := ts.Malloc(context.Background(), "1", size)
|
||||
// if err != nil {
|
||||
// t.Logf("[%d] %s", i, err)
|
||||
// return
|
||||
// }
|
||||
// t.Logf("[%d] %d -> %d", i, first+1, first+size)
|
||||
// time.Sleep(time.Second / 4)
|
||||
//}
|
||||
}
|
||||
|
||||
func TestDel(t *testing.T) {
|
||||
ts := NewTestSeq()
|
||||
t.Log(ts.GetMaxSeq(context.Background(), "1"))
|
||||
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
package cache
|
||||
|
||||
import "context"
|
||||
|
||||
type SeqConversationCache interface {
|
||||
Malloc(ctx context.Context, conversationID string, size int64) (int64, error)
|
||||
GetMaxSeq(ctx context.Context, conversationID string) (int64, error)
|
||||
SetMinSeq(ctx context.Context, conversationID string, seq int64) error
|
||||
GetMinSeq(ctx context.Context, conversationID string) (int64, error)
|
||||
}
|
@ -0,0 +1,152 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/cmd"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/config"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/cache/cachekey"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/database"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/database/mgo"
|
||||
"github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
|
||||
"github.com/openimsdk/tools/db/mongoutil"
|
||||
"github.com/openimsdk/tools/db/redisutil"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.mongodb.org/mongo-driver/bson"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"gopkg.in/yaml.v3"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const batchSize = 5
|
||||
|
||||
func readConfig[T any](dir string, name string) (*T, error) {
|
||||
data, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var conf T
|
||||
if err := yaml.Unmarshal(data, &conf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &conf, nil
|
||||
}
|
||||
|
||||
func redisKey(rdb redis.UniversalClient, prefix string, fn func(ctx context.Context, key string, delKey map[string]struct{}) error) error {
|
||||
var (
|
||||
cursor uint64
|
||||
keys []string
|
||||
err error
|
||||
)
|
||||
ctx := context.Background()
|
||||
for {
|
||||
keys, cursor, err = rdb.Scan(ctx, cursor, prefix+"*", batchSize).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delKey := make(map[string]struct{})
|
||||
if len(keys) > 0 {
|
||||
for _, key := range keys {
|
||||
if err := fn(ctx, key, delKey); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(delKey) > 0 {
|
||||
//if err := rdb.Del(ctx, datautil.Keys(delKey)...).Err(); err != nil {
|
||||
// return err
|
||||
//}
|
||||
}
|
||||
if cursor == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Main(conf string) error {
|
||||
redisConfig, err := readConfig[config.Redis](conf, cmd.RedisConfigFileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mongodbConfig, err := readConfig[config.Mongo](conf, cmd.MongodbConfigFileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
rdb, err := redisutil.NewRedisClient(ctx, redisConfig.Build())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mgocli, err := mongoutil.NewMongoDB(ctx, mongodbConfig.Build())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := mgo.NewSeqConversationMongo(mgocli.GetDB()); err != nil {
|
||||
return err
|
||||
}
|
||||
coll := mgocli.GetDB().Collection(database.SeqConversationName)
|
||||
const prefix = cachekey.MaxSeq
|
||||
return redisKey(rdb, prefix, func(ctx context.Context, key string, delKey map[string]struct{}) error {
|
||||
conversationId := strings.TrimPrefix(key, prefix)
|
||||
delKey[key] = struct{}{}
|
||||
maxValue, err := rdb.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seq, err := strconv.Atoi(maxValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid max seq %s", maxValue)
|
||||
}
|
||||
if seq == 0 {
|
||||
return nil
|
||||
}
|
||||
if seq < 0 {
|
||||
return fmt.Errorf("invalid max seq %s", maxValue)
|
||||
}
|
||||
var (
|
||||
minSeq int64
|
||||
maxSeq = int64(seq)
|
||||
)
|
||||
minKey := cachekey.MinSeq + conversationId
|
||||
delKey[minKey] = struct{}{}
|
||||
minValue, err := rdb.Get(ctx, minKey).Result()
|
||||
if err == nil {
|
||||
seq, err := strconv.Atoi(minValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid min seq %s", minValue)
|
||||
}
|
||||
if seq < 0 {
|
||||
return fmt.Errorf("invalid min seq %s", minValue)
|
||||
}
|
||||
minSeq = int64(seq)
|
||||
} else if !errors.Is(err, redis.Nil) {
|
||||
return err
|
||||
}
|
||||
if maxSeq < minSeq {
|
||||
return fmt.Errorf("invalid max seq %d < min seq %d", maxSeq, minSeq)
|
||||
}
|
||||
res, err := mongoutil.FindOne[*model.SeqConversation](ctx, coll, bson.M{"conversation_id": conversationId}, nil)
|
||||
if err == nil {
|
||||
if res.MaxSeq < int64(seq) {
|
||||
_, err = coll.UpdateOne(ctx, bson.M{"conversation_id": conversationId}, bson.M{"$set": bson.M{"max_seq": maxSeq, "min_seq": minSeq}})
|
||||
}
|
||||
return err
|
||||
} else if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
res = &model.SeqConversation{
|
||||
ConversationID: conversationId,
|
||||
MaxSeq: maxSeq,
|
||||
MinSeq: minSeq,
|
||||
}
|
||||
_, err := coll.InsertOne(ctx, res)
|
||||
return err
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
})
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/openimsdk/open-im-server/v3/tools/seq/internal"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var config string
|
||||
flag.StringVar(&config, "redis", "/Users/chao/Desktop/project/open-im-server/config", "config directory")
|
||||
flag.Parse()
|
||||
if err := internal.Main(config); err != nil {
|
||||
fmt.Println("seq task", err)
|
||||
}
|
||||
}
|
Loading…
Reference in new issue