feat(queue): add message-plane core logic for send/receive/delete/visibility
Implements SendMessage (size cap, FIFO dedup, shard routing), ReceiveMessage (long-poll loop across active+closing shards, FIFO per-group exclusivity gating, redelivery hand-out), DeleteMessage (ack + low-watermark offset advancement), and ChangeMessageVisibility against the Redis state layer and a real Kafka producer/consumer.
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
goredis "github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
kmsvcredis "github.com/rockliang/kafka-management-service/internal/redis"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChangeVisibilityService struct {
|
||||||
|
Redis *goredis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangeMessageVisibility implements ChangeMessageVisibility (design.md
|
||||||
|
// §2b): extends (or shortens) how long a received message stays invisible
|
||||||
|
// to other consumers, without affecting its receive count.
|
||||||
|
func (s *ChangeVisibilityService) ChangeMessageVisibility(ctx context.Context, queueName, receiptHandle string, newTimeout time.Duration) error {
|
||||||
|
ok, err := kmsvcredis.ExtendVisibility(ctx, s.Redis, queueName, receiptHandle, newTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("change visibility %s: %w", queueName, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("receipt handle not found or already expired")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
goredis "github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
"github.com/rockliang/kafka-management-service/internal/kafka"
|
||||||
|
kmsvcredis "github.com/rockliang/kafka-management-service/internal/redis"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OffsetCommitter is the subset of kafka.Admin DeleteMessage needs to
|
||||||
|
// advance a (shard, partition)'s committed consumer-group offset, per
|
||||||
|
// design.md §3's low-watermark strategy.
|
||||||
|
type OffsetCommitter interface {
|
||||||
|
CommitOffset(ctx context.Context, group, topic string, partition int32, offset int64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteMessageService struct {
|
||||||
|
Redis *goredis.Client
|
||||||
|
Committer OffsetCommitter
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessage acks a received message (design.md §3): the atomic ack.lua
|
||||||
|
// script removes its in-flight record + vis_index entry, removes its offset
|
||||||
|
// from the pending set, and releases any FIFO lock it held; this then
|
||||||
|
// advances the (shard, partition)'s committed watermark if doing so is safe.
|
||||||
|
// Calling DeleteMessage on an already-acked/DLQ-routed/redelivered receipt
|
||||||
|
// handle is a no-op, matching SQS's idempotent DeleteMessage semantics.
|
||||||
|
func (s *DeleteMessageService) DeleteMessage(ctx context.Context, queueName, receiptHandle string) error {
|
||||||
|
rec, ok, err := kmsvcredis.GetInFlight(ctx, s.Redis, queueName, receiptHandle)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete message %s: %w", queueName, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome, err := kmsvcredis.Ack(ctx, s.Redis, queueName, receiptHandle)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete message %s: %w", queueName, err)
|
||||||
|
}
|
||||||
|
if outcome != kmsvcredis.AckOutcomeAcked {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.advanceWatermark(ctx, queueName, rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceWatermark commits the new low-watermark for the acked message's
|
||||||
|
// (shard, partition) once the pending set has a new minimum. If nothing is
|
||||||
|
// pending (everything ever consumed on this partition has been acked), the
|
||||||
|
// last commit is left as-is rather than invented from nothing — at worst
|
||||||
|
// this means a restart replays slightly further back than strictly
|
||||||
|
// necessary, which design.md §3 accepts as the at-least-once tradeoff.
|
||||||
|
func (s *DeleteMessageService) advanceWatermark(ctx context.Context, queueName string, rec kmsvcredis.InFlightRecord) error {
|
||||||
|
minOffset, ok, err := kmsvcredis.MinPending(ctx, s.Redis, queueName, rec.ShardID, rec.Partition)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("advance watermark %s: %w", queueName, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := kmsvcredis.SetWatermark(ctx, s.Redis, queueName, rec.ShardID, rec.Partition, minOffset-1); err != nil {
|
||||||
|
return fmt.Errorf("advance watermark %s: %w", queueName, err)
|
||||||
|
}
|
||||||
|
if s.Committer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.Committer.CommitOffset(ctx, kafka.ConsumerGroup(queueName), rec.Topic, rec.Partition, minOffset); err != nil {
|
||||||
|
return fmt.Errorf("advance watermark %s: %w", queueName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
goredis "github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
kmsvcredis "github.com/rockliang/kafka-management-service/internal/redis"
|
||||||
|
)
|
||||||
|
|
||||||
|
// acquireFIFOSlot claims the per-group exclusivity gate (design.md §3) so
|
||||||
|
// ReceiveMessage never hands out two in-flight messages for the same
|
||||||
|
// MessageGroupId at once. The lock's TTL matches the message's own
|
||||||
|
// visibility timeout, so a lock left by a crashed/timed-out consumer
|
||||||
|
// self-heals on the same schedule the reaper uses (design.md §5) — it is
|
||||||
|
// also released early, by ack.lua/reap.lua, on ack/redeliver/DLQ-route.
|
||||||
|
func acquireFIFOSlot(ctx context.Context, rdb *goredis.Client, queueName, groupID, receiptHandle string, visibilityTimeout time.Duration) (bool, error) {
|
||||||
|
ok, err := kmsvcredis.AcquireFIFOLock(ctx, rdb, queueName, groupID, receiptHandle, visibilityTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("acquire fifo slot %s/%s: %w", queueName, groupID, err)
|
||||||
|
}
|
||||||
|
return ok, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
goredis "github.com/redis/go-redis/v9"
|
||||||
|
"github.com/twmb/franz-go/pkg/kfake"
|
||||||
|
|
||||||
|
"github.com/rockliang/kafka-management-service/internal/kafka"
|
||||||
|
kmsvcredis "github.com/rockliang/kafka-management-service/internal/redis"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestKafka starts an in-memory, wire-protocol-compatible fake Kafka
|
||||||
|
// cluster (kfake) so these tests exercise the real franz-go producer/
|
||||||
|
// consumer/admin code paths without needing Docker/testcontainers — not
|
||||||
|
// available in this environment, same documented tradeoff as the
|
||||||
|
// queue-operator's envtest substitution (internal/operator).
|
||||||
|
func newTestKafka(t *testing.T) []string {
|
||||||
|
t.Helper()
|
||||||
|
cluster, err := kfake.NewCluster(kfake.NumBrokers(1))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("starting kfake cluster: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(cluster.Close)
|
||||||
|
return cluster.ListenAddrs()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestRedisClient(t *testing.T) *goredis.Client {
|
||||||
|
t.Helper()
|
||||||
|
mr, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("starting miniredis: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(mr.Close)
|
||||||
|
return goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedQueue creates the queue's shard-0 topic, queue meta, and shard map
|
||||||
|
// directly — standing in for the queue-operator (internal/operator), which
|
||||||
|
// is exercised separately in its own test suite.
|
||||||
|
func seedQueue(t *testing.T, ctx context.Context, brokers []string, rdb *goredis.Client, name string, fifo bool, partitionsPerShard int32) kafka.Shard {
|
||||||
|
t.Helper()
|
||||||
|
admin, err := kafka.NewAdmin(brokers)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new admin: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(admin.Close)
|
||||||
|
|
||||||
|
shard := kafka.Shard{ID: "0", Topic: kafka.ShardTopicName(name, fifo, "0"), HashRangeStart: 0, HashRangeEnd: kafka.FullHashRangeEnd, Phase: "Active"}
|
||||||
|
if err := admin.CreateTopic(ctx, shard.Topic, kafka.TopicConfig{PartitionCount: partitionsPerShard, ReplicationFactor: 1, RetentionSeconds: 345600, MinInsyncReplicas: 1}); err != nil {
|
||||||
|
t.Fatalf("create shard topic: %v", err)
|
||||||
|
}
|
||||||
|
if err := kmsvcredis.PutQueueMeta(ctx, rdb, name, kmsvcredis.QueueMeta{
|
||||||
|
FIFO: fifo,
|
||||||
|
VisibilityTimeoutSeconds: 1,
|
||||||
|
MaxReceiveCount: 3,
|
||||||
|
PartitionsPerShard: partitionsPerShard,
|
||||||
|
RetentionSeconds: 345600,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("put queue meta: %v", err)
|
||||||
|
}
|
||||||
|
if err := kmsvcredis.PutShardMap(ctx, rdb, name, []kafka.Shard{shard}); err != nil {
|
||||||
|
t.Fatalf("put shard map: %v", err)
|
||||||
|
}
|
||||||
|
return shard
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestServices(t *testing.T, brokers []string, rdb *goredis.Client, group string, topics ...string) (*SendMessageService, *ReceiveMessageService, *DeleteMessageService) {
|
||||||
|
t.Helper()
|
||||||
|
producer, err := kafka.NewProducer(brokers)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new producer: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(producer.Close)
|
||||||
|
|
||||||
|
consumer, err := kafka.NewConsumer(brokers, group, topics...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new consumer: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(consumer.Close)
|
||||||
|
|
||||||
|
router := &ShardRouter{Redis: rdb}
|
||||||
|
send := &SendMessageService{Redis: rdb, Producer: producer, Router: router}
|
||||||
|
receive := &ReceiveMessageService{Redis: rdb, Fetcher: consumer, Router: router, PollInterval: 20 * time.Millisecond}
|
||||||
|
del := &DeleteMessageService{Redis: rdb}
|
||||||
|
return send, receive, del
|
||||||
|
}
|
||||||
|
|
||||||
|
func receiveUntil(t *testing.T, ctx context.Context, receive *ReceiveMessageService, queueName string, want int, waitTime time.Duration) []Message {
|
||||||
|
t.Helper()
|
||||||
|
out, err := receive.ReceiveMessage(ctx, ReceiveMessageInput{QueueName: queueName, MaxNumberOfMessages: int32(want), WaitTime: waitTime})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("receive message: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendReceiveDeleteStandardQueue(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
brokers := newTestKafka(t)
|
||||||
|
rdb := newTestRedisClient(t)
|
||||||
|
shard := seedQueue(t, ctx, brokers, rdb, "orders", false, 1)
|
||||||
|
send, receive, del := newTestServices(t, brokers, rdb, kafka.ConsumerGroup("orders"), shard.Topic)
|
||||||
|
|
||||||
|
if _, err := send.SendMessage(ctx, SendMessageInput{QueueName: "orders", Body: "hello"}); err != nil {
|
||||||
|
t.Fatalf("send message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs := receiveUntil(t, ctx, receive, "orders", 1, 2*time.Second)
|
||||||
|
if len(msgs) != 1 || msgs[0].Body != "hello" {
|
||||||
|
t.Fatalf("messages = %+v, want one %q", msgs, "hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := del.DeleteMessage(ctx, "orders", msgs[0].ReceiptHandle); err != nil {
|
||||||
|
t.Fatalf("delete message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing left to redeliver: a short poll should come back empty.
|
||||||
|
again := receiveUntil(t, ctx, receive, "orders", 1, 100*time.Millisecond)
|
||||||
|
if len(again) != 0 {
|
||||||
|
t.Fatalf("messages after delete = %+v, want none", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReceiveMessageRedeliversAfterVisibilityTimeoutExpires(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
brokers := newTestKafka(t)
|
||||||
|
rdb := newTestRedisClient(t)
|
||||||
|
shard := seedQueue(t, ctx, brokers, rdb, "orders", false, 1)
|
||||||
|
send, receive, _ := newTestServices(t, brokers, rdb, kafka.ConsumerGroup("orders"), shard.Topic)
|
||||||
|
|
||||||
|
if _, err := send.SendMessage(ctx, SendMessageInput{QueueName: "orders", Body: "redeliver-me"}); err != nil {
|
||||||
|
t.Fatalf("send message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := receiveUntil(t, ctx, receive, "orders", 1, 2*time.Second)
|
||||||
|
if len(first) != 1 {
|
||||||
|
t.Fatalf("first receive = %+v, want one message", first)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the reaper directly (its own service is covered in internal/redis
|
||||||
|
// and a separate Task 7 reaper loop — here we just need its effect: an
|
||||||
|
// expired in-flight message becomes redeliverable).
|
||||||
|
if _, err := kmsvcredis.Reap(ctx, rdb, "orders", first[0].ReceiptHandle, 3); err != nil {
|
||||||
|
t.Fatalf("reap: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := receiveUntil(t, ctx, receive, "orders", 1, 2*time.Second)
|
||||||
|
if len(second) != 1 || second[0].ReceiveCount != 2 {
|
||||||
|
t.Fatalf("second receive = %+v, want one message with receiveCount=2", second)
|
||||||
|
}
|
||||||
|
if second[0].Body != "redeliver-me" {
|
||||||
|
t.Fatalf("redelivered body = %q, want %q", second[0].Body, "redeliver-me")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFIFOSecondMessageInGroupNotReceivableUntilFirstAcked(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
brokers := newTestKafka(t)
|
||||||
|
rdb := newTestRedisClient(t)
|
||||||
|
shard := seedQueue(t, ctx, brokers, rdb, "orders.fifo", true, 1)
|
||||||
|
send, receive, del := newTestServices(t, brokers, rdb, kafka.ConsumerGroup("orders.fifo"), shard.Topic)
|
||||||
|
|
||||||
|
if _, err := send.SendMessage(ctx, SendMessageInput{QueueName: "orders.fifo", Body: "first", MessageGroupID: "g1"}); err != nil {
|
||||||
|
t.Fatalf("send first: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := send.SendMessage(ctx, SendMessageInput{QueueName: "orders.fifo", Body: "second", MessageGroupID: "g1"}); err != nil {
|
||||||
|
t.Fatalf("send second: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := receiveUntil(t, ctx, receive, "orders.fifo", 2, 2*time.Second)
|
||||||
|
if len(first) != 1 || first[0].Body != "first" {
|
||||||
|
t.Fatalf("first receive = %+v, want only %q", first, "first")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The second message is consumed off the partition but parked (FIFO gate
|
||||||
|
// held) — it must not be receivable yet.
|
||||||
|
blocked := receiveUntil(t, ctx, receive, "orders.fifo", 1, 200*time.Millisecond)
|
||||||
|
if len(blocked) != 0 {
|
||||||
|
t.Fatalf("messages while group locked = %+v, want none", blocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := del.DeleteMessage(ctx, "orders.fifo", first[0].ReceiptHandle); err != nil {
|
||||||
|
t.Fatalf("delete first: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := receiveUntil(t, ctx, receive, "orders.fifo", 1, 2*time.Second)
|
||||||
|
if len(second) != 1 || second[0].Body != "second" {
|
||||||
|
t.Fatalf("second receive after ack = %+v, want %q", second, "second")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMessageDedupRejectsDuplicateWithinWindow(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
brokers := newTestKafka(t)
|
||||||
|
rdb := newTestRedisClient(t)
|
||||||
|
shard := seedQueue(t, ctx, brokers, rdb, "orders.fifo", true, 1)
|
||||||
|
send, _, _ := newTestServices(t, brokers, rdb, kafka.ConsumerGroup("orders.fifo"), shard.Topic)
|
||||||
|
|
||||||
|
first, err := send.SendMessage(ctx, SendMessageInput{QueueName: "orders.fifo", Body: "v1", MessageGroupID: "g1", MessageDeduplicationID: "d1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("send first: %v", err)
|
||||||
|
}
|
||||||
|
dup, err := send.SendMessage(ctx, SendMessageInput{QueueName: "orders.fifo", Body: "v2 (should be ignored)", MessageGroupID: "g1", MessageDeduplicationID: "d1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("send dup: %v", err)
|
||||||
|
}
|
||||||
|
if dup.MessageID != "d1" {
|
||||||
|
t.Errorf("dup message id = %q, want the dedup id %q", dup.MessageID, "d1")
|
||||||
|
}
|
||||||
|
if first.SequenceNumber == dup.SequenceNumber && first.MessageID == dup.MessageID {
|
||||||
|
t.Errorf("expected dup response to be distinguishable from the original send")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendMessageRejectsBodyOverSizeCap(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
brokers := newTestKafka(t)
|
||||||
|
rdb := newTestRedisClient(t)
|
||||||
|
shard := seedQueue(t, ctx, brokers, rdb, "orders", false, 1)
|
||||||
|
send, _, _ := newTestServices(t, brokers, rdb, kafka.ConsumerGroup("orders"), shard.Topic)
|
||||||
|
|
||||||
|
oversized := make([]byte, MaxMessageBodyBytes+1)
|
||||||
|
_, err := send.SendMessage(ctx, SendMessageInput{QueueName: "orders", Body: string(oversized)})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected oversized message body to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFIFOGroupStableAcrossShardSplit(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
brokers := newTestKafka(t)
|
||||||
|
rdb := newTestRedisClient(t)
|
||||||
|
|
||||||
|
admin, err := kafka.NewAdmin(brokers)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new admin: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(admin.Close)
|
||||||
|
|
||||||
|
queueName := "orders.fifo"
|
||||||
|
parent := kafka.Shard{ID: "0", Topic: kafka.ShardTopicName(queueName, true, "0"), HashRangeStart: 0, HashRangeEnd: kafka.FullHashRangeEnd, Phase: "Active"}
|
||||||
|
for _, topic := range []string{parent.Topic} {
|
||||||
|
if err := admin.CreateTopic(ctx, topic, kafka.TopicConfig{PartitionCount: 1, ReplicationFactor: 1, RetentionSeconds: 345600, MinInsyncReplicas: 1}); err != nil {
|
||||||
|
t.Fatalf("create topic %s: %v", topic, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := kmsvcredis.PutQueueMeta(ctx, rdb, queueName, kmsvcredis.QueueMeta{FIFO: true, VisibilityTimeoutSeconds: 30, MaxReceiveCount: 3, PartitionsPerShard: 1}); err != nil {
|
||||||
|
t.Fatalf("put queue meta: %v", err)
|
||||||
|
}
|
||||||
|
if err := kmsvcredis.PutShardMap(ctx, rdb, queueName, []kafka.Shard{parent}); err != nil {
|
||||||
|
t.Fatalf("put shard map: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := &ShardRouter{Redis: rdb}
|
||||||
|
groupID := "group-A"
|
||||||
|
beforeShard, err := router.RouteForSend(ctx, queueName, groupID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("route before split: %v", err)
|
||||||
|
}
|
||||||
|
if beforeShard.ID != "0" {
|
||||||
|
t.Fatalf("beforeShard = %+v, want shard-0", beforeShard)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate the operator splitting shard-0 (internal/operator/shard_split.go).
|
||||||
|
mid := kafka.SplitHashRange(parent.HashRangeStart, parent.HashRangeEnd)
|
||||||
|
childA := kafka.Shard{ID: "1", Topic: kafka.ShardTopicName(queueName, true, "1"), HashRangeStart: 0, HashRangeEnd: mid, Phase: "Active"}
|
||||||
|
childB := kafka.Shard{ID: "2", Topic: kafka.ShardTopicName(queueName, true, "2"), HashRangeStart: mid, HashRangeEnd: kafka.FullHashRangeEnd, Phase: "Active"}
|
||||||
|
parent.Phase = "Closing"
|
||||||
|
for _, topic := range []string{childA.Topic, childB.Topic} {
|
||||||
|
if err := admin.CreateTopic(ctx, topic, kafka.TopicConfig{PartitionCount: 1, ReplicationFactor: 1, RetentionSeconds: 345600, MinInsyncReplicas: 1}); err != nil {
|
||||||
|
t.Fatalf("create topic %s: %v", topic, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := kmsvcredis.PutShardMap(ctx, rdb, queueName, []kafka.Shard{parent, childA, childB}); err != nil {
|
||||||
|
t.Fatalf("put post-split shard map: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterShard, err := router.RouteForSend(ctx, queueName, groupID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("route after split: %v", err)
|
||||||
|
}
|
||||||
|
h := kafka.HashKey(groupID)
|
||||||
|
wantID := "1"
|
||||||
|
if h >= mid {
|
||||||
|
wantID = "2"
|
||||||
|
}
|
||||||
|
if afterShard.ID != wantID {
|
||||||
|
t.Fatalf("afterShard = %+v, want shard %s (hash=%d, mid=%d)", afterShard, wantID, h, mid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiveMessage consuming across both the still-draining parent and the
|
||||||
|
// new child must still see every message, regardless of which shard it
|
||||||
|
// landed on.
|
||||||
|
send, receive, _ := newTestServices(t, brokers, rdb, kafka.ConsumerGroup(queueName), parent.Topic, childA.Topic, childB.Topic)
|
||||||
|
if _, err := send.SendMessage(ctx, SendMessageInput{QueueName: queueName, Body: "post-split", MessageGroupID: groupID}); err != nil {
|
||||||
|
t.Fatalf("send post-split: %v", err)
|
||||||
|
}
|
||||||
|
msgs := receiveUntil(t, ctx, receive, queueName, 1, 2*time.Second)
|
||||||
|
if len(msgs) != 1 || msgs[0].Body != "post-split" {
|
||||||
|
t.Fatalf("messages = %+v, want one %q", msgs, "post-split")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
goredis "github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
"github.com/rockliang/kafka-management-service/internal/kafka"
|
||||||
|
kmsvcredis "github.com/rockliang/kafka-management-service/internal/redis"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultPollInterval = 200 * time.Millisecond // design.md §2b
|
||||||
|
|
||||||
|
// Fetcher is the subset of kafka.Consumer ReceiveMessage needs: one
|
||||||
|
// non-blocking poll iteration across whatever shard topics it's currently
|
||||||
|
// subscribed to (design.md §6 — every Active+Closing shard).
|
||||||
|
type Fetcher interface {
|
||||||
|
Poll(ctx context.Context) ([]kafka.Record, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message is one message handed back to a ReceiveMessage caller.
|
||||||
|
type Message struct {
|
||||||
|
ReceiptHandle string
|
||||||
|
Body string
|
||||||
|
ReceiveCount int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReceiveMessageInput struct {
|
||||||
|
QueueName string
|
||||||
|
MaxNumberOfMessages int32
|
||||||
|
WaitTime time.Duration
|
||||||
|
VisibilityTimeoutOverride time.Duration // 0 = use the queue's configured default
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReceiveMessageService struct {
|
||||||
|
Redis *goredis.Client
|
||||||
|
Fetcher Fetcher
|
||||||
|
Router *ShardRouter
|
||||||
|
|
||||||
|
// PollInterval is the short sleep between Kafka+Redis poll iterations
|
||||||
|
// during a long-poll wait (design.md §2b); defaults to 200ms.
|
||||||
|
PollInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiveMessage implements ReceiveMessage's SQS-style long polling
|
||||||
|
// (design.md §2b): pops anything already queued for redelivery first
|
||||||
|
// (design.md §4, avoids re-fetching by offset), then polls Kafka for new
|
||||||
|
// records, looping until max_number_of_messages is satisfied or wait_time
|
||||||
|
// elapses.
|
||||||
|
func (s *ReceiveMessageService) ReceiveMessage(ctx context.Context, in ReceiveMessageInput) ([]Message, error) {
|
||||||
|
meta, ok, err := kmsvcredis.GetQueueMeta(ctx, s.Redis, in.QueueName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("receive message %s: %w", in.QueueName, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("queue %s not found", in.QueueName)
|
||||||
|
}
|
||||||
|
|
||||||
|
visTimeout := in.VisibilityTimeoutOverride
|
||||||
|
if visTimeout <= 0 {
|
||||||
|
visTimeout = time.Duration(meta.VisibilityTimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
max := in.MaxNumberOfMessages
|
||||||
|
if max <= 0 || max > 10 {
|
||||||
|
max = 10
|
||||||
|
}
|
||||||
|
interval := s.PollInterval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = defaultPollInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
shards, err := s.Router.ConsumableShards(ctx, in.QueueName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
topicToShardID := make(map[string]string, len(shards))
|
||||||
|
for _, sh := range shards {
|
||||||
|
topicToShardID[sh.Topic] = sh.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(in.WaitTime)
|
||||||
|
out := make([]Message, 0, max)
|
||||||
|
for {
|
||||||
|
for int32(len(out)) < max {
|
||||||
|
msg, handed, err := s.handOutRedeliverable(ctx, meta, in.QueueName, visTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !handed {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if int32(len(out)) < max {
|
||||||
|
// kgo's PollFetches blocks until either records are available or
|
||||||
|
// its context is done, so each iteration gets its own
|
||||||
|
// short-lived context bounded by the poll interval — otherwise a
|
||||||
|
// quiet topic would block the very first iteration for the
|
||||||
|
// entire wait_time instead of retrying every interval.
|
||||||
|
pollCtx, cancel := context.WithTimeout(ctx, interval)
|
||||||
|
records, err := s.Fetcher.Poll(pollCtx)
|
||||||
|
cancel()
|
||||||
|
if err != nil && ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return nil, fmt.Errorf("receive message %s: %w", in.QueueName, err)
|
||||||
|
}
|
||||||
|
for _, rec := range records {
|
||||||
|
if int32(len(out)) >= max {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
shardID, known := topicToShardID[rec.Topic]
|
||||||
|
if !known {
|
||||||
|
continue // topic belongs to a shard that's since closed
|
||||||
|
}
|
||||||
|
msg, handed, err := s.handOutFresh(ctx, meta, in.QueueName, shardID, rec, visTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if handed {
|
||||||
|
out = append(out, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out) > 0 || time.Now().After(deadline) {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return out, ctx.Err()
|
||||||
|
case <-time.After(interval):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReceiveMessageService) handOutRedeliverable(ctx context.Context, meta kmsvcredis.QueueMeta, queueName string, visTimeout time.Duration) (Message, bool, error) {
|
||||||
|
handle, ok, err := kmsvcredis.PopRedeliverable(ctx, s.Redis, queueName)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, false, fmt.Errorf("receive message %s: %w", queueName, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return Message{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rec, ok, err := kmsvcredis.GetInFlight(ctx, s.Redis, queueName, handle)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, false, fmt.Errorf("receive message %s: %w", queueName, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
// Acked/DLQ-routed between the push onto the redeliver list and this
|
||||||
|
// pop — nothing to hand out, caller should keep draining the list.
|
||||||
|
return Message{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec.GroupID != "" {
|
||||||
|
acquired, err := acquireFIFOSlot(ctx, s.Redis, queueName, rec.GroupID, handle, visTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, false, err
|
||||||
|
}
|
||||||
|
if !acquired {
|
||||||
|
if err := kmsvcredis.PushRedeliverable(ctx, s.Redis, queueName, handle); err != nil {
|
||||||
|
return Message{}, false, fmt.Errorf("receive message %s: %w", queueName, err)
|
||||||
|
}
|
||||||
|
return Message{}, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := kmsvcredis.ExtendVisibility(ctx, s.Redis, queueName, handle, visTimeout); err != nil {
|
||||||
|
return Message{}, false, fmt.Errorf("receive message %s: %w", queueName, err)
|
||||||
|
}
|
||||||
|
return Message{ReceiptHandle: handle, Body: rec.Body, ReceiveCount: rec.ReceiveCount}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ReceiveMessageService) handOutFresh(ctx context.Context, meta kmsvcredis.QueueMeta, queueName, shardID string, rec kafka.Record, visTimeout time.Duration) (Message, bool, error) {
|
||||||
|
if err := kmsvcredis.AddPending(ctx, s.Redis, queueName, shardID, rec.Partition, rec.Offset); err != nil {
|
||||||
|
return Message{}, false, fmt.Errorf("receive message %s: %w", queueName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
receiptHandle := newReceiptHandle(shardID, rec.Partition, rec.Offset)
|
||||||
|
groupID := ""
|
||||||
|
if meta.FIFO {
|
||||||
|
groupID = string(rec.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := kmsvcredis.PutInFlight(ctx, s.Redis, queueName, receiptHandle, kmsvcredis.InFlightRecord{
|
||||||
|
ShardID: shardID,
|
||||||
|
Topic: rec.Topic,
|
||||||
|
Partition: rec.Partition,
|
||||||
|
Offset: rec.Offset,
|
||||||
|
GroupID: groupID,
|
||||||
|
Body: string(rec.Value),
|
||||||
|
ReceiveCount: 1,
|
||||||
|
}, visTimeout); err != nil {
|
||||||
|
return Message{}, false, fmt.Errorf("receive message %s: %w", queueName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if groupID != "" {
|
||||||
|
acquired, err := acquireFIFOSlot(ctx, s.Redis, queueName, groupID, receiptHandle, visTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return Message{}, false, err
|
||||||
|
}
|
||||||
|
if !acquired {
|
||||||
|
// Another message for this group is already checked out: this
|
||||||
|
// one was already consumed off the partition (so it must be
|
||||||
|
// tracked, not dropped) but can't be delivered yet — park it on
|
||||||
|
// the redeliver list instead of handing it out now.
|
||||||
|
if err := kmsvcredis.PushRedeliverable(ctx, s.Redis, queueName, receiptHandle); err != nil {
|
||||||
|
return Message{}, false, fmt.Errorf("receive message %s: %w", queueName, err)
|
||||||
|
}
|
||||||
|
return Message{}, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Message{ReceiptHandle: receiptHandle, Body: string(rec.Value), ReceiveCount: 1}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newReceiptHandle(shardID string, partition int32, offset int64) string {
|
||||||
|
return fmt.Sprintf("%s:%d:%d:%s", shardID, partition, offset, uuid.NewString())
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
goredis "github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
"github.com/rockliang/kafka-management-service/internal/kafka"
|
||||||
|
kmsvcredis "github.com/rockliang/kafka-management-service/internal/redis"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxMessageBodyBytes is the SQS-compatible size cap enforced at
|
||||||
|
// SendMessage, design.md §6.
|
||||||
|
const MaxMessageBodyBytes = 256 * 1024
|
||||||
|
|
||||||
|
const defaultDedupWindow = 5 * time.Minute
|
||||||
|
|
||||||
|
// Producer is the subset of kafka.Producer SendMessage needs, abstracted so
|
||||||
|
// tests can substitute a fake without a real Kafka broker.
|
||||||
|
type Producer interface {
|
||||||
|
Produce(ctx context.Context, topic string, partition int32, key, value []byte) (offset int64, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendMessageInput struct {
|
||||||
|
QueueName string
|
||||||
|
Body string
|
||||||
|
MessageGroupID string // FIFO only
|
||||||
|
MessageDeduplicationID string // FIFO only
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendMessageOutput struct {
|
||||||
|
MessageID string
|
||||||
|
SequenceNumber int64 // the Kafka offset it landed at
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendMessageService struct {
|
||||||
|
Redis *goredis.Client
|
||||||
|
Producer Producer
|
||||||
|
Router *ShardRouter
|
||||||
|
DedupWindow time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage implements SendMessage (design.md §2b/§2c/§6): enforces the
|
||||||
|
// size cap, runs the FIFO dedup check, resolves the destination shard via
|
||||||
|
// the routing key, and produces to that shard's topic at the
|
||||||
|
// within-shard partition the key hashes to.
|
||||||
|
func (s *SendMessageService) SendMessage(ctx context.Context, in SendMessageInput) (SendMessageOutput, error) {
|
||||||
|
if len(in.Body) > MaxMessageBodyBytes {
|
||||||
|
return SendMessageOutput{}, fmt.Errorf("message body exceeds %d bytes", MaxMessageBodyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, ok, err := kmsvcredis.GetQueueMeta(ctx, s.Redis, in.QueueName)
|
||||||
|
if err != nil {
|
||||||
|
return SendMessageOutput{}, fmt.Errorf("send message %s: %w", in.QueueName, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return SendMessageOutput{}, fmt.Errorf("queue %s not found", in.QueueName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.FIFO {
|
||||||
|
if in.MessageGroupID == "" {
|
||||||
|
return SendMessageOutput{}, fmt.Errorf("messageGroupId is required for FIFO queue %s", in.QueueName)
|
||||||
|
}
|
||||||
|
if in.MessageDeduplicationID != "" {
|
||||||
|
window := s.DedupWindow
|
||||||
|
if window <= 0 {
|
||||||
|
window = defaultDedupWindow
|
||||||
|
}
|
||||||
|
fresh, err := kmsvcredis.TryDedup(ctx, s.Redis, in.QueueName, in.MessageGroupID, in.MessageDeduplicationID, window)
|
||||||
|
if err != nil {
|
||||||
|
return SendMessageOutput{}, fmt.Errorf("send message %s: %w", in.QueueName, err)
|
||||||
|
}
|
||||||
|
if !fresh {
|
||||||
|
// SQS returns the original send's message ID for a deduped
|
||||||
|
// retry; v1 doesn't track that mapping, so the dedup ID
|
||||||
|
// itself is returned as a stable, idempotent identifier.
|
||||||
|
return SendMessageOutput{MessageID: in.MessageDeduplicationID}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
routingKey := in.MessageGroupID
|
||||||
|
if routingKey == "" {
|
||||||
|
routingKey = uuid.NewString()
|
||||||
|
}
|
||||||
|
|
||||||
|
shard, err := s.Router.RouteForSend(ctx, in.QueueName, routingKey)
|
||||||
|
if err != nil {
|
||||||
|
return SendMessageOutput{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
partition := kafka.PartitionWithinShard(routingKey, meta.PartitionsPerShard)
|
||||||
|
var key []byte
|
||||||
|
if meta.FIFO {
|
||||||
|
key = []byte(in.MessageGroupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset, err := s.Producer.Produce(ctx, shard.Topic, partition, key, []byte(in.Body))
|
||||||
|
if err != nil {
|
||||||
|
return SendMessageOutput{}, fmt.Errorf("send message %s: %w", in.QueueName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SendMessageOutput{
|
||||||
|
MessageID: uuid.NewString(),
|
||||||
|
SequenceNumber: offset,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Package queue implements the message-plane business logic from
|
||||||
|
// design.md §2b/§3: SendMessage/ReceiveMessage/DeleteMessage/
|
||||||
|
// ChangeMessageVisibility, FIFO gating, and shard-aware routing.
|
||||||
|
package queue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
goredis "github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
"github.com/rockliang/kafka-management-service/internal/kafka"
|
||||||
|
kmsvcredis "github.com/rockliang/kafka-management-service/internal/redis"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShardRouter resolves a routing key to a shard by reading the cached shard
|
||||||
|
// map the queue-operator writes (design.md §4 `kmsvc:shardmap:`). It is the
|
||||||
|
// only piece of core logic that needs to know shards exist — everything
|
||||||
|
// downstream operates against a resolved (shard, topic) pair exactly as it
|
||||||
|
// would against a single topic.
|
||||||
|
//
|
||||||
|
// In-process caching of the shard map (design.md §4's stated optimization)
|
||||||
|
// is intentionally not implemented in v1: a direct Redis read per call is
|
||||||
|
// simpler and still cheap relative to a Kafka round trip, and can be added
|
||||||
|
// later without changing this type's interface.
|
||||||
|
type ShardRouter struct {
|
||||||
|
Redis *goredis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteForSend returns the Active shard a new message with the given
|
||||||
|
// routing key (MessageGroupId for FIFO, a random UUID for standard queues)
|
||||||
|
// should be produced to.
|
||||||
|
func (r *ShardRouter) RouteForSend(ctx context.Context, queueName, routingKey string) (kafka.Shard, error) {
|
||||||
|
shards, err := r.shards(ctx, queueName)
|
||||||
|
if err != nil {
|
||||||
|
return kafka.Shard{}, err
|
||||||
|
}
|
||||||
|
shard, found := kafka.SelectShard(kafka.ActiveShards(shards), routingKey)
|
||||||
|
if !found {
|
||||||
|
return kafka.Shard{}, fmt.Errorf("no active shard covers the routing key for queue %s", queueName)
|
||||||
|
}
|
||||||
|
return shard, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumableShards returns every shard a consumer should be subscribed to:
|
||||||
|
// Active (write+read) and Closing (read-only, draining) — everything except
|
||||||
|
// Closed, whose topic has already been deleted.
|
||||||
|
func (r *ShardRouter) ConsumableShards(ctx context.Context, queueName string) ([]kafka.Shard, error) {
|
||||||
|
shards, err := r.shards(ctx, queueName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]kafka.Shard, 0, len(shards))
|
||||||
|
for _, s := range shards {
|
||||||
|
if s.Phase != "Closed" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ShardRouter) shards(ctx context.Context, queueName string) ([]kafka.Shard, error) {
|
||||||
|
shards, ok, err := kmsvcredis.GetShardMap(ctx, r.Redis, queueName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read shard map %s: %w", queueName, err)
|
||||||
|
}
|
||||||
|
if !ok || len(shards) == 0 {
|
||||||
|
return nil, fmt.Errorf("queue %s has no shard map yet (not reconciled)", queueName)
|
||||||
|
}
|
||||||
|
return shards, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user