Files
tasks/common/persistence/cassandra/queue_v2_store.go
Yaniv KaulandGitHub 5167fe2c00 fix: handle under-filled CQL pages in ListQueues with ALLOW FILTERING (#9481)
The queues table uses composite partition key (queue_type, queue_name).
The ALLOW FILTERING query on queue_type alone may return fewer rows than
PageSize per CQL page while more data remains. Accumulate results across
multiple pages to guarantee the requested page size is filled.

## What changed?
This is an interesting and a slight change of behavior between ScyllaDB
and Cassandra. I couldn't judge from the protocol who's right. I think
it's not very very well defined.

## Why?
Tests against ScyllaDB could have failed.

## How did you test it?
- [x] built
- [x] run locally and tested manually
- [x] covered by existing tests
- [ ] added new unit test(s)
- [ ] added new functional test(s)

## Potential risks
I've tested it against both Cassandra (3.11, 5.0) and ScyllaDB (2025.4,
2026.1)
2026-05-06 09:23:09 -07:00

531 lines
17 KiB
Go

package cassandra
import (
"context"
"fmt"
commonpb "go.temporal.io/api/common/v1"
enumspb "go.temporal.io/api/enums/v1"
persistencespb "go.temporal.io/server/api/persistence/v1"
"go.temporal.io/server/common/log"
"go.temporal.io/server/common/persistence"
"go.temporal.io/server/common/persistence/nosql/nosqlplugin/cassandra/gocql"
"go.temporal.io/server/common/persistence/serialization"
)
type (
// queueV2Store contains the SQL queries and serialization/deserialization functions to interact with the queues and
// queue_messages tables that implement the QueueV2 interface. The schema is located at:
// schema/cassandra/temporal/versioned/v1.9/queues.cql
queueV2Store struct {
session gocql.Session
logger log.Logger
}
Queue struct {
Metadata *persistencespb.Queue
Version int64
}
)
const (
TemplateEnqueueMessageQuery = `INSERT INTO queue_messages (queue_type, queue_name, queue_partition, message_id, message_payload, message_encoding) VALUES (?, ?, ?, ?, ?, ?) IF NOT EXISTS`
TemplateGetMessagesQuery = `SELECT message_id, message_payload, message_encoding FROM queue_messages WHERE queue_type = ? AND queue_name = ? AND queue_partition = ? AND message_id >= ? ORDER BY message_id ASC LIMIT ?`
TemplateGetMaxMessageIDQuery = `SELECT message_id FROM queue_messages WHERE queue_type = ? AND queue_name = ? AND queue_partition = ? ORDER BY message_id DESC LIMIT 1`
TemplateCreateQueueQuery = `INSERT INTO queues (queue_type, queue_name, metadata_payload, metadata_encoding, version) VALUES (?, ?, ?, ?, ?) IF NOT EXISTS`
TemplateGetQueueQuery = `SELECT metadata_payload, metadata_encoding, version FROM queues WHERE queue_type = ? AND queue_name = ?`
TemplateRangeDeleteMessagesQuery = `DELETE FROM queue_messages WHERE queue_type = ? AND queue_name = ? AND queue_partition = ? AND message_id >= ? AND message_id <= ?`
TemplateUpdateQueueMetadataQuery = `UPDATE queues SET metadata_payload = ?, metadata_encoding = ?, version = ? WHERE queue_type = ? AND queue_name = ? IF version = ?`
// We will have to ALLOW FILTERING for this query since partition key consists of both queue_type and queue_name.
templateGetQueueNamesQuery = `SELECT queue_name, metadata_payload, metadata_encoding, version FROM queues WHERE queue_type = ? ALLOW FILTERING`
)
var (
// ErrEnqueueMessageConflict is returned when a message with the same ID already exists in the queue. This is
// possible when there are concurrent writes to the queue because we enqueue a message using two queries:
//
// 1. SELECT MAX(ID) to get the next message ID (for a given queue partition)
// 2. INSERT (ID, message) with IF NOT EXISTS
//
// See the following example:
//
// Client A Client B Cassandra DB
// | | |
// |--1. SELECT MAX(ID) FROM queue_messages----------------------->|
// | | |
// |<-2. Return X--------------------------------------------------|
// | | |
// | |--3. SELECT MAX(ID) FROM queue_messages---->|
// | | |
// | |<-4. Return X-------------------------------|
// | | |
// |--5. INSERT INTO queue_messages (ID = X)---------------------->|
// | | |
// |<-6. Acknowledge-----------------------------------------------|
// | | |
// | |--7. INSERT INTO queue_messages (ID = X)--->|
// | | |
// | |<-8. Conflict/Error-------------------------|
// | | |
ErrEnqueueMessageConflict = &persistence.ConditionFailedError{
Msg: "conflict inserting queue message, likely due to concurrent writes",
}
// ErrUpdateQueueConflict is returned when a queue is updated with the wrong version. This happens when there are
// concurrent writes to the queue because we update a queue using two queries, similar to the enqueue message query.
//
// 1. SELECT (queue, version) FROM queues
// 2. UPDATE queue, version IF version = version from step 1
//
// See the following example:
//
// Client A Client B Cassandra DB
// | | |
// |--1. SELECT (queue, version) FROM queues---------------------->|
// | | |
// |<-2. Return (queue, v1)----------------------------------------|
// | | |
// | |--3. SELECT (queue, version) FROM queues--->|
// | | |
// | |<-4. Return (queue, v1)---------------------|
// | | |
// |--5. UPDATE queue, version IF version = v1-------------------->|
// | | |
// |<-6. Acknowledge-----------------------------------------------|
// | | |
// | |--7. UPDATE queue, version IF version = v1->|
// | | |
// | |<-8. Conflict/Error-------------------------|
// | | |
ErrUpdateQueueConflict = &persistence.ConditionFailedError{
Msg: "conflict updating queue, likely due to concurrent writes",
}
)
func NewQueueV2Store(session gocql.Session, logger log.Logger) persistence.QueueV2 {
return &queueV2Store{
session: session,
logger: logger,
}
}
func (s *queueV2Store) EnqueueMessage(
ctx context.Context,
request *persistence.InternalEnqueueMessageRequest,
) (*persistence.InternalEnqueueMessageResponse, error) {
// TODO: add concurrency control around this method to avoid things like QueueMessageIDConflict.
// TODO: cache the queue in memory to avoid querying the database every time.
_, err := s.getQueue(ctx, request.QueueType, request.QueueName)
if err != nil {
return nil, err
}
lastMessageID, ok, err := s.getMaxMessageID(ctx, request.QueueType, request.QueueName)
if err != nil {
return nil, err
}
var nextMessageID int64
if !ok {
nextMessageID = persistence.FirstQueueMessageID
} else {
nextMessageID = lastMessageID + 1
}
err = s.tryInsert(ctx, request.QueueType, request.QueueName, request.Blob, nextMessageID)
if err != nil {
return nil, err
}
return &persistence.InternalEnqueueMessageResponse{
Metadata: persistence.MessageMetadata{ID: nextMessageID},
}, nil
}
func (s *queueV2Store) ReadMessages(
ctx context.Context,
request *persistence.InternalReadMessagesRequest,
) (*persistence.InternalReadMessagesResponse, error) {
q, err := s.getQueue(ctx, request.QueueType, request.QueueName)
if err != nil {
return nil, err
}
if request.PageSize <= 0 {
return nil, persistence.ErrNonPositiveReadQueueMessagesPageSize
}
minMessageID, err := persistence.GetMinMessageIDToReadForQueueV2(request.QueueType, request.QueueName, request.NextPageToken, q.Metadata)
if err != nil {
return nil, err
}
iter := s.session.Query(
TemplateGetMessagesQuery,
request.QueueType,
request.QueueName,
0,
int(minMessageID),
request.PageSize,
).WithContext(ctx).Iter()
var (
messages []persistence.QueueV2Message
// messageID is the ID of the last message returned by the query.
messageID int64
)
for {
var (
messagePayload []byte
messageEncoding string
)
if !iter.Scan(&messageID, &messagePayload, &messageEncoding) {
break
}
encoding, err := enumspb.EncodingTypeFromString(messageEncoding)
if err != nil {
return nil, serialization.NewUnknownEncodingTypeError(messageEncoding)
}
encodingType := enumspb.EncodingType(encoding)
message := persistence.QueueV2Message{
MetaData: persistence.MessageMetadata{ID: messageID},
Data: &commonpb.DataBlob{
EncodingType: encodingType,
Data: messagePayload,
},
}
messages = append(messages, message)
}
if err := iter.Close(); err != nil {
return nil, gocql.ConvertError("QueueV2ReadMessages", err)
}
nextPageToken := persistence.GetNextPageTokenForReadMessages(messages)
return &persistence.InternalReadMessagesResponse{
Messages: messages,
NextPageToken: nextPageToken,
}, nil
}
func (s *queueV2Store) CreateQueue(
ctx context.Context,
request *persistence.InternalCreateQueueRequest,
) (*persistence.InternalCreateQueueResponse, error) {
queueType := request.QueueType
queueName := request.QueueName
q := persistencespb.Queue{
Partitions: map[int32]*persistencespb.QueuePartition{
0: {
MinMessageId: persistence.FirstQueueMessageID,
},
},
}
bytes, _ := q.Marshal()
applied, err := s.session.Query(
TemplateCreateQueueQuery,
queueType,
queueName,
bytes,
enumspb.ENCODING_TYPE_PROTO3.String(),
0,
).WithContext(ctx).MapScanCAS(make(map[string]any))
if err != nil {
return nil, gocql.ConvertError("QueueV2CreateQueue", err)
}
if !applied {
return nil, fmt.Errorf(
"%w: queue type %v and name %v",
persistence.ErrQueueAlreadyExists,
queueType,
queueName,
)
}
return &persistence.InternalCreateQueueResponse{}, nil
}
func (s *queueV2Store) RangeDeleteMessages(
ctx context.Context,
request *persistence.InternalRangeDeleteMessagesRequest,
) (*persistence.InternalRangeDeleteMessagesResponse, error) {
if request.InclusiveMaxMessageMetadata.ID < persistence.FirstQueueMessageID {
return nil, fmt.Errorf(
"%w: id is %d but must be >= %d",
persistence.ErrInvalidQueueRangeDeleteMaxMessageID,
request.InclusiveMaxMessageMetadata.ID,
persistence.FirstQueueMessageID,
)
}
queueType := request.QueueType
queueName := request.QueueName
q, err := s.getQueue(ctx, queueType, queueName)
if err != nil {
return nil, err
}
partition, err := persistence.GetPartitionForQueueV2(queueType, queueName, q.Metadata)
if err != nil {
return nil, err
}
maxMessageID, ok, err := s.getMaxMessageID(ctx, queueType, queueName)
if err != nil {
return nil, err
}
if !ok {
// Nothing in the queue to delete.
return &persistence.InternalRangeDeleteMessagesResponse{}, nil
}
deleteRange, ok := persistence.GetDeleteRange(persistence.DeleteRequest{
LastIDToDeleteInclusive: request.InclusiveMaxMessageMetadata.ID,
ExistingMessageRange: persistence.InclusiveMessageRange{
MinMessageID: partition.MinMessageId,
MaxMessageID: maxMessageID,
},
})
if !ok {
return &persistence.InternalRangeDeleteMessagesResponse{}, nil
}
err = s.session.Query(
TemplateRangeDeleteMessagesQuery,
queueType,
queueName,
0, // partition
deleteRange.MinMessageID,
deleteRange.MaxMessageID,
).WithContext(ctx).Exec()
if err != nil {
return nil, gocql.ConvertError("QueueV2RangeDeleteMessages", err)
}
partition.MinMessageId = deleteRange.NewMinMessageID
err = s.updateQueue(ctx, q, queueType, queueName)
if err != nil {
return nil, err
}
return &persistence.InternalRangeDeleteMessagesResponse{
MessagesDeleted: deleteRange.MessagesToDelete,
}, nil
}
func (s *queueV2Store) updateQueue(
ctx context.Context,
q *Queue,
queueType persistence.QueueV2Type,
queueName string,
) error {
bytes, _ := q.Metadata.Marshal()
version := q.Version
nextVersion := version + 1
q.Version = nextVersion
applied, err := s.session.Query(
TemplateUpdateQueueMetadataQuery,
bytes,
enumspb.ENCODING_TYPE_PROTO3.String(),
nextVersion,
queueType,
queueName,
version,
).WithContext(ctx).MapScanCAS(make(map[string]any))
if err != nil {
return gocql.ConvertError("QueueV2UpdateQueueMetadata", err)
}
if !applied {
return fmt.Errorf(
"%w: queue type %v and name %v",
ErrUpdateQueueConflict,
queueType,
queueName,
)
}
return nil
}
func (s *queueV2Store) tryInsert(
ctx context.Context,
queueType persistence.QueueV2Type,
queueName string,
blob *commonpb.DataBlob,
messageID int64,
) error {
applied, err := s.session.Query(
TemplateEnqueueMessageQuery,
queueType,
queueName,
0,
messageID,
blob.Data,
blob.EncodingType.String(),
).WithContext(ctx).MapScanCAS(make(map[string]any))
if err != nil {
return gocql.ConvertError("QueueV2EnqueueMessage", err)
}
if !applied {
return fmt.Errorf(
"%w: queue type %v and name %v already has a message with ID %v",
ErrEnqueueMessageConflict,
queueType,
queueName,
messageID,
)
}
return nil
}
func (s *queueV2Store) getQueue(
ctx context.Context,
queueType persistence.QueueV2Type,
name string,
) (*Queue, error) {
return GetQueue(ctx, s.session, name, queueType)
}
func GetQueue(
ctx context.Context,
session gocql.Session,
queueName string,
queueType persistence.QueueV2Type,
) (*Queue, error) {
var (
queueBytes []byte
queueEncodingStr string
version int64
)
err := session.Query(TemplateGetQueueQuery, queueType, queueName).WithContext(ctx).Scan(
&queueBytes,
&queueEncodingStr,
&version,
)
if err != nil {
if gocql.IsNotFoundError(err) {
return nil, persistence.NewQueueNotFoundError(queueType, queueName)
}
return nil, gocql.ConvertError("QueueV2GetQueue", err)
}
return getQueueFromMetadata(queueType, queueName, queueBytes, queueEncodingStr, version)
}
func getQueueFromMetadata(
queueType persistence.QueueV2Type,
queueName string,
queueBytes []byte,
queueEncodingStr string,
version int64,
) (*Queue, error) {
if queueEncodingStr != enumspb.ENCODING_TYPE_PROTO3.String() {
return nil, fmt.Errorf(
"%w: invalid queue encoding type: queue with type %v and name %v has invalid encoding",
serialization.NewUnknownEncodingTypeError(queueEncodingStr, enumspb.ENCODING_TYPE_PROTO3),
queueType,
queueName,
)
}
q := &persistencespb.Queue{}
err := q.Unmarshal(queueBytes)
if err != nil {
return nil, serialization.NewDeserializationError(
enumspb.ENCODING_TYPE_PROTO3,
fmt.Errorf("%w: unmarshal queue payload: failed for queue with type %v and name %v",
err, queueType, queueName),
)
}
return &Queue{
Metadata: q,
Version: version,
}, nil
}
func (s *queueV2Store) getMessageCountAndLastID(
ctx context.Context,
queueType persistence.QueueV2Type,
queueName string,
partition *persistencespb.QueuePartition,
) (messageCount int64, maxMessageID int64, err error) {
var ok bool
maxMessageID, ok, err = s.getMaxMessageID(ctx, queueType, queueName)
if err != nil {
return 0, 0, err
}
if !ok {
return 0, -1, nil // No messages
}
messageCount = maxMessageID - partition.MinMessageId + 1
return messageCount, maxMessageID, nil
}
func (s *queueV2Store) getMaxMessageID(ctx context.Context, queueType persistence.QueueV2Type, queueName string) (int64, bool, error) {
var maxMessageID int64
err := s.session.Query(TemplateGetMaxMessageIDQuery, queueType, queueName, 0).WithContext(ctx).Scan(&maxMessageID)
if err != nil {
if gocql.IsNotFoundError(err) {
return 0, false, nil
}
return 0, false, gocql.ConvertError("QueueV2GetMaxMessageID", err)
}
return maxMessageID, true, nil
}
func (s *queueV2Store) ListQueues(
ctx context.Context,
request *persistence.InternalListQueuesRequest,
) (*persistence.InternalListQueuesResponse, error) {
if request.PageSize <= 0 {
return nil, persistence.ErrNonPositiveListQueuesPageSize
}
// The ALLOW FILTERING query may return fewer rows than PageSize per CQL
// page because the partition key is (queue_type, queue_name) and we filter
// only on queue_type. Keep fetching pages until we have enough rows or
// exhaust the result set.
var queues []persistence.QueueInfo
pageToken := request.NextPageToken
for len(queues) < request.PageSize {
remaining := request.PageSize - len(queues)
iter := s.session.Query(
templateGetQueueNamesQuery,
request.QueueType,
).PageSize(remaining).PageState(pageToken).WithContext(ctx).Iter()
for {
var (
queueName string
metadataBytes []byte
metadataEncoding string
version int64
)
if !iter.Scan(&queueName, &metadataBytes, &metadataEncoding, &version) {
break
}
q, err := getQueueFromMetadata(request.QueueType, queueName, metadataBytes, metadataEncoding, version)
if err != nil {
return nil, err
}
partition, err := persistence.GetPartitionForQueueV2(request.QueueType, queueName, q.Metadata)
if err != nil {
return nil, err
}
messageCount, lastMessageID, err := s.getMessageCountAndLastID(ctx, request.QueueType, queueName, partition)
if err != nil {
return nil, err
}
queues = append(queues, persistence.QueueInfo{
QueueName: queueName,
MessageCount: messageCount,
LastMessageID: lastMessageID,
})
}
pageToken = iter.PageState()
if err := iter.Close(); err != nil {
return nil, gocql.ConvertError("QueueV2ListQueues", err)
}
if len(pageToken) == 0 {
break
}
}
return &persistence.InternalListQueuesResponse{
Queues: queues,
NextPageToken: pageToken,
}, nil
}