Skip to content

Commit 4667dc0

Browse files
fix(payments): keep the subscription sweep cursor across passes
Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
1 parent 67a83a9 commit 4667dc0

3 files changed

Lines changed: 47 additions & 2 deletions

File tree

‎src/services/subscription-service.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,16 @@ const logger = createLogger('subscription-service')
1212
const SWEEP_PAGE_SIZE = 10
1313
const SWEEP_MAX_PAGES = 10
1414

15+
type Sweep = 'ended' | 'grace' | 'renewal'
16+
1517
export class SubscriptionService implements ISubscriptionService {
18+
/**
19+
* Where each sweep starts. Rows a pass could not handle stay at the front of its query, so the next
20+
* pass starts behind them. Without this, more unhandled rows than one pass reads would hide every
21+
* row after them. It starts over from the front once a pass reaches the end of the rows.
22+
*/
23+
private readonly cursors: Record<Sweep, number> = { ended: 0, grace: 0, renewal: 0 }
24+
1625
public constructor(
1726
private readonly userSubscriptionRepository: IUserSubscriptionRepository,
1827
private readonly paymentsService: IPaymentsService,
@@ -28,14 +37,17 @@ export class SubscriptionService implements ISubscriptionService {
2837
// Ends first, so a subscription that lapsed is not also sent a renewal invoice.
2938
const changed =
3039
(await this.sweep(
40+
'ended',
3141
(limit, offset) => this.userSubscriptionRepository.findPeriodEnded(now, limit, offset),
3242
(subscription) => this.endPeriod(subscription, currentSettings, now),
3343
)) +
3444
(await this.sweep(
45+
'grace',
3546
(limit, offset) => this.userSubscriptionRepository.findGraceEnded(now, limit, offset),
3647
(subscription) => this.expire(subscription),
3748
)) +
3849
(await this.sweep(
50+
'renewal',
3951
(limit, offset) =>
4052
this.userSubscriptionRepository.findDueForRenewal(
4153
new Date(now.getTime() + RENEWAL_LEAD_SECONDS * 1000),
@@ -52,14 +64,16 @@ export class SubscriptionService implements ISubscriptionService {
5264
* Each handled subscription leaves the query that found it, so the next page starts where the
5365
* unhandled ones end: skipping them with an offset keeps a subscription that cannot be handled,
5466
* such as one on a plan the operator removed, from blocking the ones behind it. The work of one
55-
* pass is bounded.
67+
* pass is bounded, and the next one carries on behind the rows this one skipped.
5668
*/
5769
private async sweep(
70+
sweep: Sweep,
5871
find: (limit: number, offset: number) => Promise<UserSubscription[]>,
5972
handle: (subscription: UserSubscription) => Promise<boolean>,
6073
): Promise<number> {
6174
let changed = 0
62-
let skipped = 0
75+
let skipped = this.cursors[sweep]
76+
let reachedEnd = false
6377

6478
for (let page = 0; page < SWEEP_MAX_PAGES; page++) {
6579
const subscriptions = await find(SWEEP_PAGE_SIZE, skipped)
@@ -80,10 +94,13 @@ export class SubscriptionService implements ISubscriptionService {
8094
}
8195

8296
if (subscriptions.length < SWEEP_PAGE_SIZE) {
97+
reachedEnd = true
8398
break
8499
}
85100
}
86101

102+
this.cursors[sweep] = reachedEnd ? 0 : skipped
103+
87104
return changed
88105
}
89106

‎test/unit/app/maintenance-worker.spec.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,7 @@ describe('MaintenanceWorker', () => {
373373
notificationOutboxService,
374374
notificationDeliveryLogRepository,
375375
notificationOutboxRepository,
376+
undefined,
376377
subscriptionService as any,
377378
)
378379
})

‎test/unit/services/subscription-service.spec.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,33 @@ describe('SubscriptionService', () => {
270270
expect(repository.findPeriodEnded.secondCall.args.slice(1)).to.deep.equal([10, 10])
271271
})
272272

273+
it('carries on behind the rows it skipped in the next pass, so more stuck rows than one pass reads do not hide the rest', async () => {
274+
repository.findPeriodEnded.resolves(page(10, { planId: 'retired' }))
275+
276+
await service.processLifecycle(NOW)
277+
278+
// The first pass read ten pages of stuck rows and gave up at the cap; the next starts behind them.
279+
expect(repository.findPeriodEnded.firstCall.args.slice(1)).to.deep.equal([10, 0])
280+
repository.findPeriodEnded.resetHistory()
281+
repository.findPeriodEnded.resolves([])
282+
283+
await service.processLifecycle(NOW)
284+
285+
expect(repository.findPeriodEnded.firstCall.args.slice(1)).to.deep.equal([10, 100])
286+
})
287+
288+
it('starts over from the front once a pass reaches the end of the rows', async () => {
289+
repository.findPeriodEnded.onCall(0).resolves(page(10, { planId: 'retired' }))
290+
repository.findPeriodEnded.onCall(1).resolves([])
291+
await service.processLifecycle(NOW)
292+
repository.findPeriodEnded.resetHistory()
293+
repository.findPeriodEnded.resolves([])
294+
295+
await service.processLifecycle(NOW)
296+
297+
expect(repository.findPeriodEnded.firstCall.args.slice(1)).to.deep.equal([10, 0])
298+
})
299+
273300
it('counts only the rows left behind when handled ones drop out of the next query', async () => {
274301
const mixed = [...page(7), ...page(3, { planId: 'retired' })]
275302
repository.findPeriodEnded.onCall(0).resolves(mixed)

0 commit comments

Comments
 (0)