Skip to content

Commit 8139485

Browse files
behinddwallscursoragent
authored andcommitted
feat(messagequeue): scale tenant reconciliation
## Summary ### Why? Reconciling every configured tenant on every subscription tick multiplies idle MySQL work and can exhaust the database connection budget when one process serves 100 or more tenants. ### What? - Prioritize recently active tenants across pipeline topics while sweeping idle tenants round-robin within one lease-duration window. - Preserve tenant-scoped Vitess queries and per-tenant orphan-sweep progress while sharing activity across subscriptions. - Bound each service's shared queue database pool with required QUEUE_MYSQL_MAX_OPEN_CONNECTIONS configuration and Compose defaults of 16. - Keep the gateway log-consumer integration on its configured e2e-test-queue tenant. - Add deterministic scheduler coverage for active priority, 100-tenant sweep completion, state preservation, failure isolation, and cancellation. ## Test Plan ✅ `./tool/bazel test //platform/extension/messagequeue/mysql:go_default_test //service/messagequeue:go_default_test --test_output=errors` ✅ `./tool/bazel test //test/integration/submitqueue/gateway:go_default_test --test_output=errors --strategy=TestRunner=local` ✅ `./tool/bazel test //test/e2e/submitqueue:go_default_test --test_output=errors --strategy=TestRunner=local` ✅ `./tool/bazel test //test/integration/extension/messagequeue/mysql/vitess:go_default_test --test_output=errors --strategy=TestRunner=local` ✅ `make fmt` Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c4cd74e commit 8139485

15 files changed

Lines changed: 417 additions & 37 deletions

File tree

‎platform/extension/messagequeue/mysql/subscriber.go‎

Lines changed: 182 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ type subscriber struct {
113113
mu sync.RWMutex
114114
closed bool
115115

116+
// Activity is shared across topics so a tenant stays hot through pipeline handoffs.
117+
tenantActivityMu sync.Mutex
118+
tenantActiveUntil map[string]time.Time
119+
116120
// Active subscriptions
117121
subscriptions map[string]*subscription
118122
subMu sync.Mutex
@@ -160,6 +164,11 @@ type subscription struct {
160164
// partition keys don't hold leases, offset rows, and polling workers forever.
161165
// Only accessed by the single managePartitions goroutine — no locking needed.
162166
drainedSince map[entityqueue.PartitionIdentity]time.Time
167+
168+
// Idle tenant sweeps advance independently for discovery and lease maintenance.
169+
discoveryTenantCursor int
170+
leaseTenantCursor int
171+
lastOrphanSweep map[string]time.Time
163172
}
164173

165174
func partitionKeysForTenant(partitions []entityqueue.PartitionIdentity, tenant string) []string {
@@ -181,6 +190,74 @@ func sortPartitionIdentities(partitions []entityqueue.PartitionIdentity) {
181190
})
182191
}
183192

193+
func selectTenantReconciliationBatch(
194+
tenants []string,
195+
activeTenants map[string]struct{},
196+
cursor int,
197+
tickIntervalMs int64,
198+
sweepIntervalMs int64,
199+
) ([]string, int) {
200+
selected := make([]string, 0, len(tenants))
201+
idleCount := 0
202+
for _, tenant := range tenants {
203+
if _, active := activeTenants[tenant]; active {
204+
selected = append(selected, tenant)
205+
continue
206+
}
207+
idleCount++
208+
}
209+
if idleCount == 0 {
210+
return selected, 0
211+
}
212+
213+
ticksPerSweep := max(int((sweepIntervalMs+tickIntervalMs-1)/tickIntervalMs), 1)
214+
idlePerTick := max((idleCount+ticksPerSweep-1)/ticksPerSweep, 1)
215+
cursor %= len(tenants)
216+
nextCursor := cursor
217+
added := 0
218+
for scanned := 0; scanned < len(tenants) && added < idlePerTick; scanned++ {
219+
index := (cursor + scanned) % len(tenants)
220+
nextCursor = (index + 1) % len(tenants)
221+
if _, active := activeTenants[tenants[index]]; active {
222+
continue
223+
}
224+
selected = append(selected, tenants[index])
225+
added++
226+
}
227+
return selected, nextCursor
228+
}
229+
230+
func (s *subscriber) activeTenantsForSubscription(sub *subscription, now time.Time) map[string]struct{} {
231+
active := make(map[string]struct{})
232+
s.tenantActivityMu.Lock()
233+
for tenant, until := range s.tenantActiveUntil {
234+
if until.After(now) {
235+
active[tenant] = struct{}{}
236+
continue
237+
}
238+
delete(s.tenantActiveUntil, tenant)
239+
}
240+
s.tenantActivityMu.Unlock()
241+
242+
sub.workersMu.Lock()
243+
defer sub.workersMu.Unlock()
244+
for _, partition := range sub.lastDiscoveredPartitions {
245+
active[partition.Tenant] = struct{}{}
246+
}
247+
for partition := range sub.workers {
248+
active[partition.Tenant] = struct{}{}
249+
}
250+
return active
251+
}
252+
253+
func (s *subscriber) markTenantActive(tenant string, until time.Time) {
254+
s.tenantActivityMu.Lock()
255+
defer s.tenantActivityMu.Unlock()
256+
if current := s.tenantActiveUntil[tenant]; current.Before(until) {
257+
s.tenantActiveUntil[tenant] = until
258+
}
259+
}
260+
184261
type tenantOperationResult[T any] struct {
185262
tenant string
186263
value T
@@ -501,6 +578,7 @@ func NewSubscriber(logger *zap.SugaredLogger, scope tally.Scope, messageStore me
501578
deliveryStateStore: deliveryStateStore,
502579
tenants: tenants,
503580
shutdownTimeout: subscriptionShutdownTimeout,
581+
tenantActiveUntil: make(map[string]time.Time, len(tenants)),
504582
subscriptions: make(map[string]*subscription),
505583
}
506584
}
@@ -613,12 +691,13 @@ func (s *subscriber) Subscribe(ctx context.Context, topic string, config extqueu
613691
// effective for a caller whose ctx never completes.
614692
subCtx, cancel := context.WithCancel(ctx)
615693
sub := &subscription{
616-
topic: topic,
617-
config: config,
618-
deliveryCh: make(chan extqueue.Delivery, config.BatchSize*2),
619-
cancelFunc: cancel,
620-
done: make(chan struct{}),
621-
workers: make(map[entityqueue.PartitionIdentity]*partitionWorker),
694+
topic: topic,
695+
config: config,
696+
deliveryCh: make(chan extqueue.Delivery, config.BatchSize*2),
697+
cancelFunc: cancel,
698+
done: make(chan struct{}),
699+
workers: make(map[entityqueue.PartitionIdentity]*partitionWorker),
700+
lastOrphanSweep: make(map[string]time.Time, len(s.tenants)),
622701
}
623702

624703
s.subscriptions[subKey] = sub
@@ -668,26 +747,36 @@ func (s *subscriber) managePartitions(ctx context.Context, sub *subscription) {
668747
"subscriber_name", cfg.SubscriberName,
669748
}
670749

671-
discoveryTicker := time.NewTicker(time.Duration(cfg.PartitionDiscoveryIntervalMs) * time.Millisecond)
750+
discoveryInterval := time.Duration(cfg.PartitionDiscoveryIntervalMs) * time.Millisecond
751+
discoveryTicker := time.NewTicker(discoveryInterval)
672752
defer discoveryTicker.Stop()
673753

674-
leaseTicker := time.NewTicker(time.Duration(cfg.LeaseRenewalIntervalMs) * time.Millisecond)
754+
leaseInterval := time.Duration(cfg.LeaseRenewalIntervalMs) * time.Millisecond
755+
leaseTicker := time.NewTicker(leaseInterval)
675756
defer leaseTicker.Stop()
757+
tenantSweepIntervalMs := cfg.LeaseDurationMs
676758

677759
// Orphan sweep pacing: an uncapped acquisition pass runs every two lease
678760
// durations. Two lease durations is past every transient window in the
679761
// protocol — an expiring lease, a crashed peer's heartbeat going stale —
680762
// so anything still unleased at sweep time is genuinely unclaimed.
681763
orphanSweepInterval := 2 * time.Duration(cfg.LeaseDurationMs) * time.Millisecond
682-
lastOrphanSweep := time.Now()
683-
684-
// Send initial heartbeat so this subscriber is immediately visible to
685-
// ActiveSubscribers. Without this, other subscribers compute incorrect
686-
// fair shares until the first leaseTicker fires.
687-
// Initial heartbeat failure is non-fatal — the next leaseTicker fires within
688-
// LeaseRenewalIntervalMs and retries.
689-
tenantLeaseTimeout := time.Duration(cfg.LeaseRenewalIntervalMs) * time.Millisecond
690-
for _, result := range runTenantOperations(ctx, s.tenants, tenantLeaseTimeout, func(tenantCtx context.Context, tenant string) (struct{}, error) {
764+
for _, tenant := range s.tenants {
765+
sub.lastOrphanSweep[tenant] = time.Now()
766+
}
767+
768+
// Idle tenants are spread across one lease-duration window; active tenants
769+
// join every pass so their heartbeats and leases retain the normal cadence.
770+
tenantLeaseTimeout := leaseInterval
771+
initialTenants, nextLeaseCursor := selectTenantReconciliationBatch(
772+
s.tenants,
773+
s.activeTenantsForSubscription(sub, time.Now()),
774+
sub.leaseTenantCursor,
775+
cfg.LeaseRenewalIntervalMs,
776+
tenantSweepIntervalMs,
777+
)
778+
sub.leaseTenantCursor = nextLeaseCursor
779+
for _, result := range runTenantOperations(ctx, initialTenants, tenantLeaseTimeout, func(tenantCtx context.Context, tenant string) (struct{}, error) {
691780
return struct{}{}, s.sendHeartbeat(tenantCtx, sub, tenant)
692781
}) {
693782
if result.err != nil {
@@ -719,7 +808,15 @@ func (s *subscriber) managePartitions(ctx context.Context, sub *subscription) {
719808
return
720809

721810
case <-leaseTicker.C:
722-
runTenantOperations(ctx, s.tenants, tenantLeaseTimeout, func(tenantCtx context.Context, tenant string) (struct{}, error) {
811+
leaseTenants, nextCursor := selectTenantReconciliationBatch(
812+
s.tenants,
813+
s.activeTenantsForSubscription(sub, time.Now()),
814+
sub.leaseTenantCursor,
815+
cfg.LeaseRenewalIntervalMs,
816+
tenantSweepIntervalMs,
817+
)
818+
sub.leaseTenantCursor = nextCursor
819+
runTenantOperations(ctx, leaseTenants, tenantLeaseTimeout, func(tenantCtx context.Context, tenant string) (struct{}, error) {
723820
tenantFields := append(logFields, "tenant", tenant)
724821
// Fetch leased partitions once for this tenant tick — shared by
725822
// rebalance and renewLeases to avoid redundant queries.
@@ -786,11 +883,23 @@ func (s *subscriber) managePartitions(ctx context.Context, sub *subscription) {
786883
// subscriber that heartbeats without acquiring) is picked up by
787884
// whichever subscriber sweeps first. An over-cap grab is shed at
788885
// the next rebalance once a peer has spare capacity to take it.
789-
uncapped := time.Since(lastOrphanSweep) >= orphanSweepInterval
790-
if uncapped {
791-
lastOrphanSweep = time.Now()
886+
discoveryTenants, nextCursor := selectTenantReconciliationBatch(
887+
s.tenants,
888+
s.activeTenantsForSubscription(sub, time.Now()),
889+
sub.discoveryTenantCursor,
890+
cfg.PartitionDiscoveryIntervalMs,
891+
tenantSweepIntervalMs,
892+
)
893+
sub.discoveryTenantCursor = nextCursor
894+
uncappedTenants := make(map[string]struct{})
895+
now := time.Now()
896+
for _, tenant := range discoveryTenants {
897+
if now.Sub(sub.lastOrphanSweep[tenant]) >= orphanSweepInterval {
898+
uncappedTenants[tenant] = struct{}{}
899+
sub.lastOrphanSweep[tenant] = now
900+
}
792901
}
793-
if err := s.discoverAndReconcileWorkers(ctx, sub, uncapped); err != nil {
902+
if err := s.discoverAndReconcileWorkers(ctx, sub, discoveryTenants, uncappedTenants); err != nil {
794903
s.logger.Errorw("partition discovery failed, will retry on next tick", append(logFields, "error", err)...)
795904
}
796905
s.emitSignal(SignalPartitionUpdate)
@@ -800,20 +909,33 @@ func (s *subscriber) managePartitions(ctx context.Context, sub *subscription) {
800909

801910
// discoverAndReconcileWorkers discovers new partitions and reconciles workers.
802911
// Uses fair share to limit how many partitions this subscriber acquires;
803-
// uncapped skips the fair-share cap entirely (the orphan sweep).
804-
func (s *subscriber) discoverAndReconcileWorkers(ctx context.Context, sub *subscription, uncapped bool) error {
805-
if len(s.tenants) == 0 {
912+
// tenants in uncappedTenants skip the fair-share cap (the orphan sweep).
913+
func (s *subscriber) discoverAndReconcileWorkers(
914+
ctx context.Context,
915+
sub *subscription,
916+
tenants []string,
917+
uncappedTenants map[string]struct{},
918+
) error {
919+
if len(tenants) == 0 {
806920
return nil
807921
}
808922

809923
cfg := sub.config
810924

811925
sub.workersMu.Lock()
812926
cachedDiscovered := append([]entityqueue.PartitionIdentity(nil), sub.lastDiscoveredPartitions...)
927+
cachedLeased := make([]entityqueue.PartitionIdentity, 0, len(sub.workers))
928+
for partition := range sub.workers {
929+
cachedLeased = append(cachedLeased, partition)
930+
}
813931
sub.workersMu.Unlock()
814932

815-
discoveredByTenant := make(map[string][]string, len(s.tenants))
816-
leasedByTenant := make(map[string][]string, len(s.tenants))
933+
attemptedTenants := make(map[string]struct{}, len(tenants))
934+
for _, tenant := range tenants {
935+
attemptedTenants[tenant] = struct{}{}
936+
}
937+
discoveredByTenant := make(map[string][]string, len(tenants))
938+
leasedByTenant := make(map[string][]string, len(tenants))
817939
var discoveryErrs []error
818940

819941
type tenantDiscovery struct {
@@ -824,7 +946,7 @@ func (s *subscriber) discoverAndReconcileWorkers(ctx context.Context, sub *subsc
824946
time.Duration(cfg.PartitionDiscoveryIntervalMs)*time.Millisecond,
825947
time.Duration(cfg.LeaseRenewalIntervalMs)*time.Millisecond,
826948
)
827-
results := runTenantOperations(ctx, s.tenants, discoveryTimeout, func(tenantCtx context.Context, tenant string) (tenantDiscovery, error) {
949+
results := runTenantOperations(ctx, tenants, discoveryTimeout, func(tenantCtx context.Context, tenant string) (tenantDiscovery, error) {
828950
leasedPartitions, err := s.leaseStore.GetLeasedPartitions(tenantCtx, tenant, sub.topic, cfg.SubscriberName, cfg.ConsumerGroup)
829951
if err != nil {
830952
return tenantDiscovery{}, fmt.Errorf("get leased partitions tenant=%s: %w", tenant, err)
@@ -833,7 +955,7 @@ func (s *subscriber) discoverAndReconcileWorkers(ctx context.Context, sub *subsc
833955
cachedForTenant := partitionKeysForTenant(cachedDiscovered, tenant)
834956

835957
maxPartitions := 0
836-
if !uncapped {
958+
if _, uncapped := uncappedTenants[tenant]; !uncapped {
837959
maxPartitions, err = s.fairShareCap(tenantCtx, sub, tenant, leasedPartitions, cachedForTenant)
838960
if err != nil {
839961
return tenantDiscovery{}, fmt.Errorf("compute fair share cap tenant=%s: %w", tenant, err)
@@ -849,14 +971,28 @@ func (s *subscriber) discoverAndReconcileWorkers(ctx context.Context, sub *subsc
849971
if err != nil {
850972
return tenantDiscovery{}, fmt.Errorf("get leased partitions after acquire tenant=%s: %w", tenant, err)
851973
}
974+
if len(discoveredPartitions) > 0 || len(leasedPartitions) > 0 {
975+
if err := s.sendHeartbeat(tenantCtx, sub, tenant); err != nil {
976+
s.logger.Errorw("heartbeat on tenant activation failed",
977+
"tenant", tenant,
978+
"topic", sub.topic,
979+
"consumer_group", cfg.ConsumerGroup,
980+
"error", err,
981+
)
982+
}
983+
}
852984
return tenantDiscovery{discovered: discoveredPartitions, leased: leasedPartitions}, nil
853985
})
854986

987+
activeUntil := time.Now().Add(time.Duration(cfg.LeaseDurationMs) * time.Millisecond)
855988
for _, result := range results {
856989
if result.err != nil {
857990
discoveryErrs = append(discoveryErrs, result.err)
858991
continue
859992
}
993+
if len(result.value.discovered) > 0 || len(result.value.leased) > 0 {
994+
s.markTenantActive(result.tenant, activeUntil)
995+
}
860996
discoveredByTenant[result.tenant] = result.value.discovered
861997
leasedByTenant[result.tenant] = result.value.leased
862998
}
@@ -869,6 +1005,23 @@ func (s *subscriber) discoverAndReconcileWorkers(ctx context.Context, sub *subsc
8691005
var expired []entityqueue.PartitionIdentity
8701006

8711007
for _, tenant := range s.tenants {
1008+
if _, attempted := attemptedTenants[tenant]; !attempted {
1009+
for _, pk := range partitionKeysForTenant(cachedDiscovered, tenant) {
1010+
allDiscovered = append(allDiscovered, entityqueue.PartitionIdentity{Tenant: tenant, PartitionKey: pk})
1011+
}
1012+
for _, partition := range cachedLeased {
1013+
if partition.Tenant == tenant {
1014+
allLeased = append(allLeased, partition)
1015+
}
1016+
}
1017+
for partition, since := range sub.drainedSince {
1018+
if partition.Tenant == tenant {
1019+
nextDrainedSince[partition] = since
1020+
}
1021+
}
1022+
continue
1023+
}
1024+
8721025
discoveredPartitions, succeeded := discoveredByTenant[tenant]
8731026
if !succeeded {
8741027
// Unconfirmed leases must not keep workers polling: a peer can acquire after expiry.

0 commit comments

Comments
 (0)