@@ -18,12 +18,14 @@ import (
1818
1919// Dunning worker errors.
2020var (
21- ErrNilDunningCallback = errors .New ("dunning callback is required" )
22- ErrDunningKeyTooShort = errors .New ("dunning retry key too short" )
21+ ErrNilDunningCallback = errors .New ("dunning callback is required" )
22+ ErrDunningKeyTooShort = errors .New ("dunning retry key too short" )
23+ ErrDunningMissingTenant = errors .New ("tenant ID is required for dunning retry" )
2324)
2425
25- // dunningRetryZSet is the Redis sorted set key for dunning retry scheduling.
26- const dunningRetryZSet = "dunning:retries"
26+ // dunningRetryZSetPrefix is the Redis sorted set key prefix for dunning retry scheduling.
27+ // The full key is "dunning:retries:{tenantID}" for tenant isolation.
28+ const dunningRetryZSetPrefix = "dunning:retries:"
2729
2830// DunningWorkerConfig holds configuration for the dunning retry worker.
2931type DunningWorkerConfig struct {
@@ -145,42 +147,75 @@ func (w *DunningWorker) pollLoop(ctx context.Context) error {
145147 }
146148}
147149
148- // ScheduleDunningRetry adds a billing run to the sorted set with a score
149- // equal to the Unix timestamp when the retry becomes due.
150- func (w * DunningWorker ) ScheduleDunningRetry (ctx context.Context , billingRunID uuid.UUID , delay time.Duration ) error {
150+ // ScheduleDunningRetry adds a billing run to the tenant-scoped sorted set with
151+ // a score equal to the Unix timestamp when the retry becomes due.
152+ func (w * DunningWorker ) ScheduleDunningRetry (ctx context.Context , tenantID string , billingRunID uuid.UUID , delay time.Duration ) error {
153+ if tenantID == "" {
154+ return ErrDunningMissingTenant
155+ }
151156 dueAt := NowFunc ().Add (delay )
152157 member := redis.Z {
153158 Score : float64 (dueAt .Unix ()),
154159 Member : billingRunID .String (),
155160 }
156- err := w .redis .ZAdd (ctx , dunningRetryZSet , member ).Err ()
161+ key := dunningRetryZSetPrefix + tenantID
162+ err := w .redis .ZAdd (ctx , key , member ).Err ()
157163 if err != nil {
158164 return fmt .Errorf ("failed to schedule dunning retry: %w" , err )
159165 }
160166
161167 w .logger .Info ("dunning retry scheduled" ,
162168 "billing_run_id" , billingRunID ,
169+ "tenant_id" , tenantID ,
163170 "delay" , delay ,
164171 "due_at" , dueAt )
165172
166173 return nil
167174}
168175
169- // processDueRetries queries the sorted set for all members whose score (due
170- // timestamp) is at or before the current time, processes each one, and removes
171- // it from the set.
176+ // processDueRetries scans for all tenant-scoped dunning ZSET keys and processes
177+ // due retries from each. Uses SCAN to discover keys matching "dunning:retries:*".
172178func (w * DunningWorker ) processDueRetries (ctx context.Context ) {
179+ // Discover all tenant-scoped dunning keys
180+ keys , err := w .scanDunningKeys (ctx )
181+ if err != nil {
182+ w .logger .Error ("failed to scan dunning retry keys" , "error" , err )
183+ return
184+ }
185+
186+ for _ , key := range keys {
187+ w .processDueRetriesForKey (ctx , key )
188+ }
189+ }
190+
191+ // scanDunningKeys returns all Redis keys matching the dunning retry ZSET pattern.
192+ func (w * DunningWorker ) scanDunningKeys (ctx context.Context ) ([]string , error ) {
193+ var allKeys []string
194+ pattern := dunningRetryZSetPrefix + "*"
195+ iter := w .redis .Scan (ctx , 0 , pattern , 100 ).Iterator ()
196+ for iter .Next (ctx ) {
197+ allKeys = append (allKeys , iter .Val ())
198+ }
199+ if err := iter .Err (); err != nil {
200+ return nil , fmt .Errorf ("failed to scan dunning keys: %w" , err )
201+ }
202+ return allKeys , nil
203+ }
204+
205+ // processDueRetriesForKey queries a single tenant's sorted set for all members
206+ // whose score (due timestamp) is at or before the current time, processes each
207+ // one, and removes it from the set.
208+ func (w * DunningWorker ) processDueRetriesForKey (ctx context.Context , key string ) {
173209 now := NowFunc ()
174210 maxScore := strconv .FormatInt (now .Unix (), 10 )
175211
176- // Fetch billing run IDs that are due
177- members , err := w .redis .ZRangeByScore (ctx , dunningRetryZSet , & redis.ZRangeBy {
212+ members , err := w .redis .ZRangeByScore (ctx , key , & redis.ZRangeBy {
178213 Min : "-inf" ,
179214 Max : maxScore ,
180215 Count : 100 ,
181216 }).Result ()
182217 if err != nil {
183- w .logger .Error ("failed to query dunning retries" , "error" , err )
218+ w .logger .Error ("failed to query dunning retries" , "key" , key , " error" , err )
184219 return
185220 }
186221
@@ -192,20 +227,19 @@ func (w *DunningWorker) processDueRetries(ctx context.Context) {
192227 for _ , member := range members {
193228 billingRunID , parseErr := uuid .Parse (member )
194229 if parseErr != nil {
195- w .logger .Error ("invalid billing run ID in dunning set" , "member" , member , "error" , parseErr )
196- w .redis .ZRem (ctx , dunningRetryZSet , member )
230+ w .logger .Error ("invalid billing run ID in dunning set" , "key" , key , " member" , member , "error" , parseErr )
231+ w .redis .ZRem (ctx , key , member )
197232 continue
198233 }
199234
200235 if w .processRetry (ctx , billingRunID ) {
201- // Only remove on success; transient failures retain the member for next poll
202- w .redis .ZRem (ctx , dunningRetryZSet , member )
236+ w .redis .ZRem (ctx , key , member )
203237 processed ++
204238 }
205239 }
206240
207241 if processed > 0 {
208- w .logger .Info ("processed dunning retries" , "count" , processed )
242+ w .logger .Info ("processed dunning retries" , "key" , key , " count" , processed )
209243 }
210244}
211245
@@ -283,8 +317,12 @@ func (w *DunningWorker) executeRetry(ctx context.Context, billingRunID uuid.UUID
283317 return true
284318}
285319
286- // CancelDunningRetry removes a billing run from the retry set.
320+ // CancelDunningRetry removes a billing run from the tenant-scoped retry set.
287321// Called when a billing run is resolved (e.g., manual payment succeeds).
288- func (w * DunningWorker ) CancelDunningRetry (ctx context.Context , billingRunID uuid.UUID ) error {
289- return w .redis .ZRem (ctx , dunningRetryZSet , billingRunID .String ()).Err ()
322+ func (w * DunningWorker ) CancelDunningRetry (ctx context.Context , tenantID string , billingRunID uuid.UUID ) error {
323+ if tenantID == "" {
324+ return ErrDunningMissingTenant
325+ }
326+ key := dunningRetryZSetPrefix + tenantID
327+ return w .redis .ZRem (ctx , key , billingRunID .String ()).Err ()
290328}
0 commit comments