-
Notifications
You must be signed in to change notification settings - Fork 1
[PB-5977]: feat(account-purge): implement account purge functionality with scheduling #112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,8 @@ mta: | |
| webhook: | ||
| username: REPLACE_ME | ||
|
|
||
| executeCronjobs: false | ||
|
|
||
| secretName: mail-server-secrets | ||
|
|
||
| probes: | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; | ||
| import { Test, type TestingModule } from '@nestjs/testing'; | ||
| import { createMock, type DeepMocked } from '@golevelup/ts-vitest'; | ||
| import { ConfigService } from '@nestjs/config'; | ||
| import { AccountPurgeService } from './account-purge.service.js'; | ||
| import { AccountService } from './account.service.js'; | ||
| import { AccountRepository } from './repositories/account.repository.js'; | ||
|
|
||
| const NOW = new Date('2026-08-21T12:00:00.000Z'); | ||
| const RETENTION_DAYS = 30; | ||
| const BATCH_SIZE = 100; | ||
| const STALLED_AFTER_MINUTES = 60; | ||
|
|
||
| describe('AccountPurgeService', () => { | ||
| let service: AccountPurgeService; | ||
| let accounts: DeepMocked<AccountRepository>; | ||
| let accountService: DeepMocked<AccountService>; | ||
| let config: DeepMocked<ConfigService>; | ||
|
|
||
| beforeEach(async () => { | ||
| vi.useFakeTimers(); | ||
| vi.setSystemTime(NOW); | ||
|
|
||
| const module: TestingModule = await Test.createTestingModule({ | ||
| providers: [AccountPurgeService], | ||
| }) | ||
| .useMocker(() => createMock<object>()) | ||
| .compile(); | ||
|
|
||
| service = module.get(AccountPurgeService); | ||
| accounts = module.get(AccountRepository); | ||
| accountService = module.get(AccountService); | ||
| config = module.get(ConfigService); | ||
|
|
||
| config.get.mockImplementation((key: string) => { | ||
| const values: Record<string, number> = { | ||
| 'accounts.suspendedRetentionDays': RETENTION_DAYS, | ||
| 'accounts.purgeBatchSize': BATCH_SIZE, | ||
| 'accounts.purgeStalledAfterMinutes': STALLED_AFTER_MINUTES, | ||
| }; | ||
| return values[key] as never; | ||
| }); | ||
|
|
||
| accounts.claimStalledDeletions.mockResolvedValue([]); | ||
| accounts.claimExpiredSuspended.mockResolvedValue([]); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.useRealTimers(); | ||
| }); | ||
|
|
||
| it('when accounts are past retention, then claims and deletes each of them', async () => { | ||
| accounts.claimExpiredSuspended.mockResolvedValue([ | ||
| { id: 'acc-1', userId: 'user-1' }, | ||
| { id: 'acc-2', userId: 'user-2' }, | ||
| ]); | ||
|
|
||
| const summary = await service.purgeExpiredAccounts(); | ||
|
|
||
| expect(accounts.claimExpiredSuspended).toHaveBeenCalledWith({ | ||
| suspendedBefore: new Date('2026-07-22T12:00:00.000Z'), | ||
| limit: BATCH_SIZE, | ||
| }); | ||
| expect(accountService.deleteAccount).toHaveBeenCalledWith('user-1'); | ||
| expect(accountService.deleteAccount).toHaveBeenCalledWith('user-2'); | ||
| expect(summary).toEqual({ claimed: 2, purged: 2, failed: 0 }); | ||
| }); | ||
|
|
||
| it('when nothing is due, then reports an empty run without deleting anything', async () => { | ||
| const summary = await service.purgeExpiredAccounts(); | ||
|
|
||
| expect(accountService.deleteAccount).not.toHaveBeenCalled(); | ||
| expect(summary).toEqual({ claimed: 0, purged: 0, failed: 0 }); | ||
| }); | ||
|
|
||
| it('when a claim has gone stale, then it is retried before newly expired ones', async () => { | ||
| accounts.claimStalledDeletions.mockResolvedValue([ | ||
| { id: 'acc-stuck', userId: 'user-stuck' }, | ||
| ]); | ||
|
|
||
| await service.purgeExpiredAccounts({ batchSize: 3 }); | ||
|
|
||
| expect(accounts.claimStalledDeletions).toHaveBeenCalledWith({ | ||
| updatedBefore: new Date('2026-08-21T11:00:00.000Z'), | ||
| limit: 3, | ||
| }); | ||
| expect(accounts.claimExpiredSuspended).toHaveBeenCalledWith({ | ||
| suspendedBefore: new Date('2026-07-22T12:00:00.000Z'), | ||
| limit: 2, | ||
| }); | ||
| expect(accountService.deleteAccount).toHaveBeenCalledWith('user-stuck'); | ||
| }); | ||
|
|
||
| it('when stalled claims fill the batch, then no new accounts are claimed', async () => { | ||
| accounts.claimStalledDeletions.mockResolvedValue([ | ||
| { id: 'acc-1', userId: 'user-1' }, | ||
| { id: 'acc-2', userId: 'user-2' }, | ||
| ]); | ||
|
|
||
| await service.purgeExpiredAccounts({ batchSize: 2 }); | ||
|
|
||
| expect(accounts.claimExpiredSuspended).toHaveBeenCalledWith({ | ||
| suspendedBefore: new Date('2026-07-22T12:00:00.000Z'), | ||
| limit: 0, | ||
| }); | ||
| }); | ||
|
|
||
| it('when one account fails, then the rest of the batch still runs', async () => { | ||
| accounts.claimExpiredSuspended.mockResolvedValue([ | ||
| { id: 'acc-1', userId: 'user-1' }, | ||
| { id: 'acc-2', userId: 'user-2' }, | ||
| { id: 'acc-3', userId: 'user-3' }, | ||
| ]); | ||
| accountService.deleteAccount.mockImplementation((userId: string) => | ||
| userId === 'user-2' | ||
| ? Promise.reject(new Error('Bridge refused')) | ||
| : Promise.resolve(), | ||
| ); | ||
|
|
||
| const summary = await service.purgeExpiredAccounts(); | ||
|
|
||
| expect(accountService.deleteAccount).toHaveBeenCalledWith('user-3'); | ||
| expect(summary).toEqual({ claimed: 3, purged: 2, failed: 1 }); | ||
| }); | ||
|
|
||
| it('when the batch size is zero, then nothing is claimed at all', async () => { | ||
| const summary = await service.purgeExpiredAccounts({ batchSize: 0 }); | ||
|
|
||
| expect(accounts.claimStalledDeletions).not.toHaveBeenCalled(); | ||
| expect(accounts.claimExpiredSuspended).not.toHaveBeenCalled(); | ||
| expect(summary).toEqual({ claimed: 0, purged: 0, failed: 0 }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import { Injectable, Logger } from '@nestjs/common'; | ||
| import { ConfigService } from '@nestjs/config'; | ||
| import dayjs from 'dayjs'; | ||
| import { AccountService } from './account.service.js'; | ||
| import { | ||
| AccountRepository, | ||
| type ClaimedAccount, | ||
| } from './repositories/account.repository.js'; | ||
|
|
||
| export interface PurgeOptions { | ||
| batchSize?: number; | ||
| } | ||
|
|
||
| export interface PurgeSummary { | ||
| claimed: number; | ||
| purged: number; | ||
| failed: number; | ||
| } | ||
|
|
||
| @Injectable() | ||
| export class AccountPurgeService { | ||
| private readonly logger = new Logger(AccountPurgeService.name); | ||
|
|
||
| constructor( | ||
| private readonly accounts: AccountRepository, | ||
| private readonly accountService: AccountService, | ||
| private readonly config: ConfigService, | ||
| ) {} | ||
|
|
||
| async purgeExpiredAccounts( | ||
| options: PurgeOptions = {}, | ||
| ): Promise<PurgeSummary> { | ||
| const batchSize = | ||
| options.batchSize ?? this.config.get<number>('accounts.purgeBatchSize')!; | ||
| const claimed = await this.claimBatch(batchSize); | ||
|
|
||
| if (claimed.length === 0) { | ||
| return { claimed: 0, purged: 0, failed: 0 }; | ||
| } | ||
|
|
||
| let purged = 0; | ||
| let failed = 0; | ||
|
|
||
| for (const account of claimed) { | ||
| try { | ||
| await this.accountService.deleteAccount(account.userId); | ||
| purged++; | ||
| } catch (error) { | ||
| failed++; | ||
| this.logger.error( | ||
| `Failed to purge account '${account.id}' for user '${account.userId}': ${(error as Error).message}`, | ||
| (error as Error).stack, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| this.logger.log( | ||
| `Purge run finished: claimed=${claimed.length} purged=${purged} failed=${failed}`, | ||
| ); | ||
|
|
||
| return { claimed: claimed.length, purged, failed }; | ||
| } | ||
|
|
||
| private async claimBatch(batchSize: number): Promise<ClaimedAccount[]> { | ||
| if (batchSize <= 0) return []; | ||
|
|
||
| const stalled = await this.accounts.claimStalledDeletions({ | ||
| updatedBefore: dayjs() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better extract this to a constant so it is more readable. |
||
| .subtract( | ||
| this.config.get<number>('accounts.purgeStalledAfterMinutes')!, | ||
| 'minute', | ||
| ) | ||
| .toDate(), | ||
| limit: batchSize, | ||
| }); | ||
|
|
||
| const expired = await this.accounts.claimExpiredSuspended({ | ||
| suspendedBefore: dayjs() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same |
||
| .subtract( | ||
| this.config.get<number>('accounts.suspendedRetentionDays')!, | ||
| 'day', | ||
| ) | ||
| .toDate(), | ||
| limit: batchSize - stalled.length, | ||
| }); | ||
|
Comment on lines
+64
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift A permanently failing account can starve new purges.
Add an attempt count or a 🤖 Prompt for AI Agents |
||
|
|
||
| return [...stalled, ...expired]; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Reject invalid account-purge settings at startup.
Number.parseIntaccepts partial values such as"10minutes"and allowsNaN, zero, and negative values. Validate both settings as positive integers before adding them to configuration. Invalid batch sizes or stalled-run timeouts can make the purge job fail or use unsafe recovery timing.Proposed validation
🤖 Prompt for AI Agents