-
Notifications
You must be signed in to change notification settings - Fork 241
feat: cron to enqueue inactive organizations for deletion #2418
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
Merged
wilsonrivera
merged 38 commits into
main
from
wilson/eng-7753-delete-inactive-organizations
Jun 15, 2026
Merged
Changes from 1 commit
Commits
Show all changes
38 commits
Select commit
Hold shift + click to select a range
4804e55
feat: create script to enqueue inactive organizations for deletion
wilsonrivera a0c978a
chore: update script and tests
wilsonrivera ea9ecda
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera e82d01a
chore: linting and tests
wilsonrivera 67c0a22
chore: remove non-null assertion
wilsonrivera 9b98d21
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 2394618
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera df04df0
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 27dcdf9
chore: implement as organization cleanup as cron job
wilsonrivera d7cd912
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera b24bb91
chore: fix test
wilsonrivera e1c4068
Merge remote-tracking branch 'origin/wilson/eng-7753-delete-inactive-…
wilsonrivera 8ac01c0
chore: fix commented `continue`
wilsonrivera 4cf6c72
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 499ec46
chore: update schedule to run once a month
wilsonrivera d0b68fb
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 9911158
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera cee6641
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 70b005c
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera f709e35
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 0a2bfb8
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 37f0fb7
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 53101a0
chore: minor update
wilsonrivera c393bf1
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera bca330c
feat: switch from `bullmq` scheduled job to Kubernetes cronjob
wilsonrivera d35b434
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera e795a34
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera c3cfba2
chore: shorten job name
wilsonrivera 6c2e6bc
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 7439a22
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera a43bb5c
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 855d3f1
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 6fa047f
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 1bd6174
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 147cd1b
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 3eec169
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera 8851027
Merge branch 'main' into wilson/eng-7753-delete-inactive-organizations
wilsonrivera b9eeb91
chore: update docs
wilsonrivera File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| import 'dotenv/config'; | ||
|
|
||
| import process from 'node:process'; | ||
| import { subDays, startOfMonth, addDays } from 'date-fns'; | ||
| import postgres from 'postgres'; | ||
| import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js'; | ||
| import { and, count, eq, gte, isNull, lt, sql } from 'drizzle-orm'; | ||
| import { pino } from 'pino'; | ||
| import { buildDatabaseConnectionConfig } from '../core/plugins/database.js'; | ||
| import * as schema from '../db/schema.js'; | ||
| import { DeleteOrganizationQueue } from '../core/workers/DeleteOrganizationWorker.js'; | ||
| import { createRedisConnections } from '../core/plugins/redis.js'; | ||
| import { OrganizationRepository } from '../core/repositories/OrganizationRepository.js'; | ||
| import { NotifyOrganizationDeletionQueuedQueue } from '../core/workers/NotifyOrganizationDeletionQueuedWorker.js'; | ||
| import { getConfig } from './get-config.js'; | ||
|
|
||
| // Number of concurrent tasks. We'll allocate the same number of database connections + 1, so keep this number reasonable | ||
| const MAX_DEGREE_OF_PARALLELISM = 5; | ||
|
|
||
| // How many organizations to retrieve from the database to migrate in a transaction. This is used to not load | ||
| // all organizations at once and perform the migration in buckets | ||
| const ORGANIZATIONS_PER_BUCKET = 100; | ||
|
|
||
| // The number of days the organization needs to be inactive for before we consider it for deletion | ||
| const MIN_INACTIVITY_DAYS = 90; | ||
|
|
||
| // How long should we wait before deleting the organization? | ||
| const DELAY_FOR_ORG_DELETION_IN_DAYS = 7; | ||
|
|
||
| const { databaseConnectionUrl, databaseTlsCa, databaseTlsCert, databaseTlsKey, redis } = getConfig(); | ||
|
|
||
| try { | ||
| const connectionConfig = await buildDatabaseConnectionConfig({ | ||
| tls: | ||
| databaseTlsCa || databaseTlsCert || databaseTlsKey | ||
| ? { | ||
| ca: databaseTlsCa, | ||
| cert: databaseTlsCert, | ||
| key: databaseTlsKey, | ||
| } | ||
| : undefined, | ||
| }); | ||
| const queryConnection = postgres(databaseConnectionUrl, { | ||
| ...connectionConfig, | ||
| max: MAX_DEGREE_OF_PARALLELISM + 1, | ||
| }); | ||
|
|
||
| // Initialize the Redis connection | ||
| const { redisQueue, redisWorker } = await createRedisConnections({ | ||
| host: redis.host!, | ||
| port: Number(redis.port), | ||
| password: redis.password, | ||
| tls: redis.tls, | ||
| }); | ||
|
|
||
| await redisQueue.connect(); | ||
| await redisWorker.connect(); | ||
| await redisWorker.ping(); | ||
| await redisQueue.ping(); | ||
|
|
||
| try { | ||
| const logger = pino(); | ||
| const db = drizzle(queryConnection, { schema: { ...schema } }); | ||
|
|
||
| await queueOrganizationsForDeletion({ | ||
| db, | ||
| deleteOrganizationQueue: new DeleteOrganizationQueue(logger, redisQueue), | ||
| notifyOrganizationDeletionQueuedQueue: new NotifyOrganizationDeletionQueuedQueue(logger, redisQueue), | ||
| }); | ||
| } finally { | ||
| redisQueue.disconnect(); | ||
| redisWorker.disconnect(); | ||
|
|
||
| // Close the database connection | ||
| await queryConnection.end({ | ||
| timeout: 1, | ||
| }); | ||
| } | ||
|
|
||
| // eslint-disable-next-line unicorn/no-process-exit | ||
| process.exit(0); | ||
| } catch (err: any) { | ||
| console.error(err); | ||
| // eslint-disable-next-line unicorn/no-process-exit | ||
| process.exit(1); | ||
| } | ||
|
|
||
| function chunkArray<T>(data: T[]): T[][] { | ||
| // @ts-ignore | ||
| if (MAX_DEGREE_OF_PARALLELISM === 1) { | ||
| return [data]; | ||
| } | ||
|
|
||
| const chunks: T[][] = []; | ||
| const organizationsPerChunk = Math.ceil(ORGANIZATIONS_PER_BUCKET / MAX_DEGREE_OF_PARALLELISM); | ||
| for (let i = 0; i < data.length; i += organizationsPerChunk) { | ||
| chunks.push(data.slice(i, i + organizationsPerChunk)); | ||
| } | ||
|
|
||
| return chunks; | ||
| } | ||
|
wilsonrivera marked this conversation as resolved.
Outdated
|
||
|
|
||
| async function queueOrganizationsForDeletion({ | ||
| db, | ||
| deleteOrganizationQueue, | ||
| notifyOrganizationDeletionQueuedQueue, | ||
| }: { | ||
| db: PostgresJsDatabase<typeof schema>; | ||
| deleteOrganizationQueue: DeleteOrganizationQueue; | ||
| notifyOrganizationDeletionQueuedQueue: NotifyOrganizationDeletionQueuedQueue; | ||
| }) { | ||
| // First, retrieve all the organizations that only have a single user and have not had any activity registered in | ||
| // the audit log for the last `MIN_INACTIVITY_DAYS` days | ||
| const now = new Date(); | ||
| const inactivityThreshold = startOfMonth(subDays(now, MIN_INACTIVITY_DAYS)); | ||
|
|
||
| console.log(`Retrieving organizations with a single member...`); | ||
| const organizations = await db | ||
| .select({ | ||
| id: schema.organizations.id, | ||
| slug: schema.organizations.slug, | ||
| userId: schema.organizations.createdBy, | ||
| }) | ||
| .from(schema.organizations) | ||
| .innerJoin(schema.organizationsMembers, eq(schema.organizationsMembers.organizationId, schema.organizations.id)) | ||
| .where( | ||
| and(isNull(schema.organizations.queuedForDeletionAt), lt(schema.organizations.createdAt, inactivityThreshold)), | ||
|
wilsonrivera marked this conversation as resolved.
Outdated
|
||
| ) | ||
|
wilsonrivera marked this conversation as resolved.
Outdated
|
||
| .groupBy(schema.organizations.id) | ||
| .having(sql`COUNT(${schema.organizationsMembers.id}) = 1`) | ||
| .execute(); | ||
|
|
||
| if (organizations.length === 0) { | ||
| console.log('No organizations found with a single member'); | ||
| return; | ||
| } | ||
|
|
||
| console.log(`${organizations.length} organizations with a single member found`); | ||
|
|
||
| // Break the organizations in chunk so we can have some degree of parallelism | ||
| console.log('Processing organizations...'); | ||
| await Promise.all( | ||
| chunkArray(organizations).map((chunk) => | ||
| db.transaction((tx) => { | ||
| return processChunkOfOrganizations({ | ||
| organizations: chunk, | ||
| db: tx, | ||
| inactivityThreshold, | ||
| deleteOrganizationQueue, | ||
| notifyOrganizationDeletionQueuedQueue, | ||
| }); | ||
| }), | ||
| ), | ||
| ); | ||
|
wilsonrivera marked this conversation as resolved.
Outdated
|
||
|
|
||
| console.log('Done!'); | ||
| } | ||
|
|
||
| async function processChunkOfOrganizations({ | ||
| organizations, | ||
| db, | ||
| inactivityThreshold, | ||
| deleteOrganizationQueue, | ||
| notifyOrganizationDeletionQueuedQueue, | ||
| }: { | ||
| organizations: { id: string; slug: string; userId: string | null }[]; | ||
| db: PostgresJsDatabase<typeof schema>; | ||
| inactivityThreshold: Date; | ||
| deleteOrganizationQueue: DeleteOrganizationQueue; | ||
| notifyOrganizationDeletionQueuedQueue: NotifyOrganizationDeletionQueuedQueue; | ||
| }) { | ||
| const queuedAt = new Date(); | ||
| const deletesAt = addDays(queuedAt, DELAY_FOR_ORG_DELETION_IN_DAYS); | ||
|
|
||
| const orgRepo = new OrganizationRepository(pino(), db, undefined); | ||
| for (const org of organizations) { | ||
| const auditLogs = await db | ||
| .select({ count: count() }) | ||
| .from(schema.auditLogs) | ||
| .where(and(eq(schema.auditLogs.organizationId, org.id), gte(schema.auditLogs.createdAt, inactivityThreshold))) | ||
| .execute(); | ||
|
|
||
| if (auditLogs.length > 0 && auditLogs[0].count > 0) { | ||
| // The organization has had activity registered in the audit, at least once in the last `MIN_INACTIVITY_DAYS` days, | ||
| // so we don't need to consider it for deletion | ||
| continue; | ||
| } | ||
|
|
||
| console.log(`\tEnqueuing organization "${org.slug}" for deletion at ${deletesAt.toISOString()}...`); | ||
|
|
||
| // Enqueue the organization deletion job | ||
| await orgRepo.queueOrganizationDeletion({ | ||
| organizationId: org.id, | ||
| queuedBy: undefined, | ||
| deleteOrganizationQueue, | ||
| deleteDelayInDays: DELAY_FOR_ORG_DELETION_IN_DAYS, | ||
| }); | ||
|
|
||
| // Queue the organization deletion notification job | ||
| await notifyOrganizationDeletionQueuedQueue.addJob({ | ||
| organizationId: org.id, | ||
| queuedAt: queuedAt.getTime(), | ||
| deletesAt: deletesAt.getTime(), | ||
| }); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.