Skip to content

Commit 669ef69

Browse files
[2/4 messagequeue] Shard MySQL queues by tenant identity
## Summary ### Why? Vitess needs a stable vindex that is not the Kafka-style partition key. Isolation has to be an explicit tenant column, with identity carried on publish and consume so two tenants sharing a partition key cannot collide. ### What? - Prefix every queue table and store query with tenant; set queue_offsets PK to (tenant, topic, partition_key, consumer_group). - Use typed (tenant, partitionKey) identities in consumers, gates, and MySQL workers; stop workers when lease discovery cannot confirm ownership. - Require tenant on publish.Message and validate payload queue against message tenant in pipeline controllers (the publish signature change is not compilable without those call sites). - Add ParseRequiredTenants and wire Stovepipe ingest to the configured tenant list so NewIngestController still builds. - Require tenant xor all-tenants on MQ admin list commands; message inspect/delete/requeue take the full (tenant, topic, partition, id) identity. ## Test Plan ✅ `go test` on mysql, publish, consumer, messagequeue identity, service/messagequeue, and start controller Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8a03bbd commit 669ef69

156 files changed

Lines changed: 4653 additions & 1896 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎platform/base/messagequeue/BUILD.bazel‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ go_library(
44
name = "go_default_library",
55
srcs = [
66
"context.go",
7+
"identity.go",
78
"message.go",
9+
"partition.go",
810
],
911
importpath = "github.com/uber/submitqueue/platform/base/messagequeue",
1012
visibility = ["//visibility:public"],
@@ -14,8 +16,12 @@ go_test(
1416
name = "go_default_test",
1517
srcs = [
1618
"context_test.go",
19+
"identity_test.go",
1720
"message_test.go",
1821
],
1922
embed = [":go_default_library"],
20-
deps = ["@com_github_stretchr_testify//assert:go_default_library"],
23+
deps = [
24+
"@com_github_stretchr_testify//assert:go_default_library",
25+
"@com_github_stretchr_testify//require:go_default_library",
26+
],
2127
)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package messagequeue
16+
17+
import "fmt"
18+
19+
// ValidateTenantMetadata verifies that the persisted shard identity agrees
20+
// with queue metadata when metadata is present.
21+
func ValidateTenantMetadata(message Message) error {
22+
if message.Tenant == "" {
23+
return fmt.Errorf("message tenant is required")
24+
}
25+
queueName := message.Metadata[MetadataKeyQueueName]
26+
if queueName != "" && queueName != message.Tenant {
27+
return fmt.Errorf("message tenant %q does not match queue metadata %q", message.Tenant, queueName)
28+
}
29+
return nil
30+
}
31+
32+
// ValidatePayloadQueue verifies that a queue-bearing payload belongs to the
33+
// persisted message tenant.
34+
func ValidatePayloadQueue(message Message, payloadQueue string) error {
35+
if err := ValidateTenantMetadata(message); err != nil {
36+
return err
37+
}
38+
if payloadQueue == "" {
39+
return fmt.Errorf("payload queue is required")
40+
}
41+
if payloadQueue != message.Tenant {
42+
return fmt.Errorf("message tenant %q does not match payload queue %q", message.Tenant, payloadQueue)
43+
}
44+
return nil
45+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package messagequeue
16+
17+
import (
18+
"testing"
19+
20+
"github.com/stretchr/testify/require"
21+
)
22+
23+
func TestValidateTenantMetadata(t *testing.T) {
24+
tests := []struct {
25+
name string
26+
message Message
27+
wantErr bool
28+
}{
29+
{name: "tenant without metadata", message: Message{Tenant: "queue-a"}},
30+
{name: "matching metadata", message: Message{Tenant: "queue-a", Metadata: map[string]string{MetadataKeyQueueName: "queue-a"}}},
31+
{name: "empty tenant", message: Message{}, wantErr: true},
32+
{name: "conflicting metadata", message: Message{Tenant: "queue-a", Metadata: map[string]string{MetadataKeyQueueName: "queue-b"}}, wantErr: true},
33+
}
34+
35+
for _, tt := range tests {
36+
t.Run(tt.name, func(t *testing.T) {
37+
err := ValidateTenantMetadata(tt.message)
38+
if tt.wantErr {
39+
require.Error(t, err)
40+
return
41+
}
42+
require.NoError(t, err)
43+
})
44+
}
45+
}
46+
47+
func TestValidatePayloadQueue(t *testing.T) {
48+
tests := []struct {
49+
name string
50+
message Message
51+
payloadQueue string
52+
wantErr bool
53+
}{
54+
{name: "matching queue", message: Message{Tenant: "queue-a"}, payloadQueue: "queue-a"},
55+
{name: "empty payload queue", message: Message{Tenant: "queue-a"}, wantErr: true},
56+
{name: "conflicting payload queue", message: Message{Tenant: "queue-a"}, payloadQueue: "queue-b", wantErr: true},
57+
{name: "conflicting metadata", message: Message{Tenant: "queue-a", Metadata: map[string]string{MetadataKeyQueueName: "queue-b"}}, payloadQueue: "queue-a", wantErr: true},
58+
}
59+
60+
for _, tt := range tests {
61+
t.Run(tt.name, func(t *testing.T) {
62+
err := ValidatePayloadQueue(tt.message, tt.payloadQueue)
63+
if tt.wantErr {
64+
require.Error(t, err)
65+
return
66+
}
67+
require.NoError(t, err)
68+
})
69+
}
70+
}

‎platform/base/messagequeue/message.go‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,15 @@ type Message struct {
4343
// Use for trace IDs, request IDs, and cross-service metadata.
4444
Metadata map[string]string
4545

46-
// PartitionKey determines which partition/shard this message goes to.
47-
// Messages with the same PartitionKey are guaranteed ordered delivery.
46+
// PartitionKey determines the ordering partition within Tenant.
47+
// Messages with the same Tenant and PartitionKey are delivered in order.
4848
// Optional - if empty, backend may use round-robin distribution.
4949
PartitionKey string
5050

51+
// Tenant is the shard isolation identity persisted by the backend.
52+
// Domains map their queue name onto this field at publish time.
53+
Tenant string
54+
5155
// PublishedAt is when the message was published (Unix milliseconds).
5256
PublishedAt int64
5357
}
@@ -79,6 +83,7 @@ func (m Message) Copy() Message {
7983
Payload: payloadCopy,
8084
Metadata: maps.Clone(m.Metadata),
8185
PartitionKey: m.PartitionKey,
86+
Tenant: m.Tenant,
8287
PublishedAt: m.PublishedAt,
8388
}
8489
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package messagequeue
16+
17+
// PartitionIdentity uniquely identifies a partition within a tenant.
18+
// It is comparable and may be used as a map key.
19+
type PartitionIdentity struct {
20+
// Tenant is the shard isolation identity.
21+
Tenant string
22+
// PartitionKey is the partition key within the tenant.
23+
PartitionKey string
24+
}
25+
26+
// PartitionIdentity returns the message's tenant-scoped partition identity.
27+
func (m Message) PartitionIdentity() PartitionIdentity {
28+
return PartitionIdentity{
29+
Tenant: m.Tenant,
30+
PartitionKey: m.PartitionKey,
31+
}
32+
}

‎platform/consumer/consumer.go‎

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"time"
2323

2424
"github.com/uber-go/tally"
25+
"github.com/uber/submitqueue/platform/base/failure"
2526
entityqueue "github.com/uber/submitqueue/platform/base/messagequeue"
2627
"github.com/uber/submitqueue/platform/errs"
2728
"github.com/uber/submitqueue/platform/extension/consumergate"
@@ -282,10 +283,10 @@ func (m *consumer) consumeLoop(ctx context.Context, controller Controller, topic
282283
"topic_key", topicKey,
283284
)
284285

285-
// partitionChs maps partition keys to per-partition delivery channels.
286+
// partitionChs maps tenant-scoped partitions to delivery channels.
286287
// Each channel is created lazily on the first message for that partition
287288
// and is never removed — partitions are stable for the lifetime of a subscription.
288-
partitionChs := make(map[string]chan extqueue.Delivery)
289+
partitionChs := make(map[entityqueue.PartitionIdentity]chan extqueue.Delivery)
289290
var wg sync.WaitGroup
290291

291292
for {
@@ -307,15 +308,18 @@ func (m *consumer) consumeLoop(ctx context.Context, controller Controller, topic
307308
m.shutdownPartitions(partitionChs, &wg)
308309
return
309310
}
311+
if m.rejectDeliveryWithInvalidTenant(ctx, controller, delivery) {
312+
continue
313+
}
310314

311315
// Route delivery to its partition's channel, creating the channel
312316
// and spawning a processPartition goroutine if this is the first
313317
// message for that partition.
314-
partitionKey := delivery.Message().PartitionKey
315-
ch, exists := partitionChs[partitionKey]
318+
partition := delivery.Message().PartitionIdentity()
319+
ch, exists := partitionChs[partition]
316320
if !exists {
317321
ch = make(chan extqueue.Delivery, batchSize)
318-
partitionChs[partitionKey] = ch
322+
partitionChs[partition] = ch
319323
wg.Add(1)
320324
go func(pCh <-chan extqueue.Delivery) {
321325
defer wg.Done()
@@ -336,9 +340,32 @@ func (m *consumer) consumeLoop(ctx context.Context, controller Controller, topic
336340
}
337341
}
338342

343+
func (m *consumer) rejectDeliveryWithInvalidTenant(ctx context.Context, controller Controller, delivery extqueue.Delivery) bool {
344+
msg := delivery.Message()
345+
if err := entityqueue.ValidateTenantMetadata(msg); err != nil {
346+
m.logger.Errorw("rejecting message with inconsistent queue identity",
347+
"controller", controller.Name(),
348+
"topic_key", controller.TopicKey(),
349+
"message_id", msg.ID,
350+
"tenant", msg.Tenant,
351+
"error", err,
352+
)
353+
if rejectErr := delivery.Reject(ctx, failure.New(err.Error())); rejectErr != nil {
354+
m.logger.Errorw("failed to reject message with inconsistent queue identity",
355+
"controller", controller.Name(),
356+
"topic_key", controller.TopicKey(),
357+
"message_id", msg.ID,
358+
"error", rejectErr,
359+
)
360+
}
361+
return true
362+
}
363+
return false
364+
}
365+
339366
// shutdownPartitions closes all partition channels to signal processPartition
340367
// goroutines to exit, then waits for them to finish draining.
341-
func (m *consumer) shutdownPartitions(partitionChs map[string]chan extqueue.Delivery, wg *sync.WaitGroup) {
368+
func (m *consumer) shutdownPartitions(partitionChs map[entityqueue.PartitionIdentity]chan extqueue.Delivery, wg *sync.WaitGroup) {
342369
for _, ch := range partitionChs {
343370
close(ch)
344371
}
@@ -373,11 +400,9 @@ func (m *consumer) processDelivery(ctx context.Context, controller Controller, d
373400
const opName = "process"
374401

375402
msg := delivery.Message()
376-
queueName := msg.Metadata[entityqueue.MetadataKeyQueueName]
403+
queueName := msg.Tenant
377404
ctx = entityqueue.WithQueueName(ctx, queueName)
378-
if queueName != "" {
379-
ctx = metrics.WithContextTags(ctx, metrics.NewTag("queue", queueName))
380-
}
405+
ctx = metrics.WithContextTags(ctx, metrics.NewTag("queue", queueName))
381406

382407
// Consumer gate: a delivery whose gate is closed is recorded as parked and
383408
// postponed (barrier + re-check on redelivery); a false return also covers
@@ -575,7 +600,10 @@ func (m *consumer) checkGate(ctx context.Context, controller Controller, deliver
575600
consumerGroup := controller.ConsumerGroup()
576601
topic := controller.TopicKey().String()
577602

578-
entry, err := m.gate.Enter(ctx, consumergate.Key{ConsumerGroup: consumerGroup, PartitionKey: msg.PartitionKey})
603+
entry, err := m.gate.Enter(ctx, consumergate.Key{
604+
ConsumerGroup: consumerGroup,
605+
Partition: msg.PartitionIdentity(),
606+
})
579607
if err != nil {
580608
if errors.Is(err, context.Canceled) {
581609
// Shutting down: leave the delivery in flight; visibility lapses
@@ -708,10 +736,11 @@ func (m *consumer) unsubscribeAll(timeoutMs int64) error {
708736
var timedOutControllers []string
709737
for topicKey, sub := range m.subscriptions {
710738
start := time.Now()
739+
timer := time.NewTimer(remaining)
711740
select {
712741
case <-sub.done:
713-
// Controller stopped gracefully
714-
case <-time.After(remaining):
742+
timer.Stop()
743+
case <-timer.C:
715744
m.logger.Errorw("timeout waiting for controller to stop",
716745
"controller", sub.controller.Name(),
717746
"topic_key", topicKey,

0 commit comments

Comments
 (0)