@@ -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
165174func 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+
184261type 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