Repository: NangoHQ/nango
Pull request #3712: fix(cron): delete all old data
Author: login: bodinsamuel
Created: 2025-03-14T18:47:53Z | Merged: 2025-03-24T10:14:09Z

## Changes

Fixes https://linear.app/nango/issue/NAN-2912/delete-old-data

- Cron: delete all old data
  - delete soft deleted connections and associated data
  - delete soft deleted sync configs and associated data
  - delete soft deleted integrations and associated data
  - delete oauth sessions the same way
  - delete expired invitations

There are 3 db queries in the cron because there are super-specific and not filtered, I wasn't too sure it was a footgun putting them in the service. But let me know.

Everything is batched because it's almost exponential since each step can contain N children. Most of the time it will be too verbose and could have been batched but it's better than slow query.

- Remove cron in jobs
The only drawbacks is that we won't delete records straight away but this cron has always been tricky 


## 🧪 Tests 
- Set env var

```sh
CRON_DELETE_OLD_DATA_EVERY_MIN=1
CRON_DELETE_OLD_CONNECT_SESSION_MAX_DAYS=0
CRON_DELETE_OLD_PRIVATE_KEYS_MAX_DAYS=0
CRON_DELETE_OLD_OAUTH_SESSION_MAX_DAYS=0
CRON_DELETE_OLD_INVITATIONS_MAX_DAYS=0
CRON_DELETE_OLD_CONFIGS_MAX_DAYS=0
CRON_DELETE_OLD_SYNC_CONFIGS_MAX_DAYS=0
CRON_DELETE_OLD_CONNECTIONS_MAX_DAYS=0
```

- Delete one connection in the UI
Should delete one connection after 1 minute
- Delete an integration with a connection
Should delete one connection and an integration after 1 minute
- Delete an integration with a connection with records
Should delete records, one connection and an integration after 1 minute


**After deleting an integration with records**
![Screenshot 2025-03-14 at 19 45 33](https://github.com/user-attachments/assets/992f8da6-085a-478e-a349-95e03fa4e563)
![Screenshot 2025-03-14 at 19 45 41](https://github.com/user-attachments/assets/61b505cc-f058-4424-9ad1-21ffb4088f48)

> It's quite verbose so we can't miss it.



Diff:
diff --git a/package-lock.json b/package-lock.json
index d7ef543dd0..ff1e280b11 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26331,23 +26331,6 @@
             "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==",
             "license": "MIT"
         },
-        "node_modules/node-cron": {
-            "version": "3.0.3",
-            "license": "ISC",
-            "dependencies": {
-                "uuid": "8.3.2"
-            },
-            "engines": {
-                "node": ">=6.0.0"
-            }
-        },
-        "node_modules/node-cron/node_modules/uuid": {
-            "version": "8.3.2",
-            "license": "MIT",
-            "bin": {
-                "uuid": "dist/bin/uuid"
-            }
-        },
         "node_modules/node-domexception": {
             "version": "1.0.0",
             "dev": true,
@@ -37262,7 +37245,6 @@
                 "dd-trace": "5.21.0",
                 "express": "4.20.0",
                 "get-port": "7.1.0",
-                "node-cron": "3.0.3",
                 "rate-limiter-flexible": "5.0.3",
                 "redis": "4.6.13",
                 "zod": "3.24.1"
diff --git a/packages/jobs/lib/app.ts b/packages/jobs/lib/app.ts
index 488c4e6bcb..936d3e59fa 100644
--- a/packages/jobs/lib/app.ts
+++ b/packages/jobs/lib/app.ts
@@ -1,7 +1,5 @@
 import './tracer.js';
 
-import * as cron from 'node-cron';
-
 import db from '@nangohq/database';
 import { generateImage } from '@nangohq/fleet';
 import { destroy as destroyKvstore } from '@nangohq/kvstore';
@@ -9,7 +7,6 @@ import { destroy as destroyLogs, otlp } from '@nangohq/logs';
 import { getOtlpRoutes } from '@nangohq/shared';
 import { getLogger, initSentry, once, report, stringifyError } from '@nangohq/utils';
 
-import { deleteSyncsData } from './crons/deleteSyncsData.js';
 import { envs } from './env.js';
 import { Processor } from './processor/processor.js';
 import { runnersFleet } from './runner/fleet.js';
@@ -64,8 +61,6 @@ try {
         logger.info('Closing...');
         clearTimeout(healthCheck);
 
-        cron.getTasks().forEach((task) => task.stop());
-
         // eslint-disable-next-line @typescript-eslint/no-misused-promises
         srv.close(async () => {
             processor.stop();
@@ -102,9 +97,6 @@ try {
 
     processor.start();
 
-    // Register recurring tasks
-    deleteSyncsData();
-
     void otlp.register(getOtlpRoutes);
 } catch (err) {
     logger.error(stringifyError(err));
diff --git a/packages/jobs/lib/crons/deleteSyncsData.ts b/packages/jobs/lib/crons/deleteSyncsData.ts
deleted file mode 100644
index 18416199d8..0000000000
--- a/packages/jobs/lib/crons/deleteSyncsData.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import * as cron from 'node-cron';
-import db from '@nangohq/database';
-import { errorManager, ErrorSourceEnum, hardDeleteJobs, findRecentlyDeletedSync, Orchestrator } from '@nangohq/shared';
-import { records } from '@nangohq/records';
-import { getLogger, metrics } from '@nangohq/utils';
-import { orchestratorClient } from '../clients.js';
-
-const logger = getLogger('Jobs');
-
-const limitJobs = 1000;
-const limitRecords = 1000;
-
-export function deleteSyncsData(): void {
-    /**
-     * Clean data from soft deleted syncs.
-     * This cron needs to be removed at some point, we need a queue to delete specific provider/connection/sync
-     */
-    cron.schedule('*/20 * * * *', async () => {
-        const start = Date.now();
-        try {
-            await exec();
-
-            logger.info('[deleteSyncs] ✅ done');
-        } catch (err) {
-            const e = new Error('failed_to_hard_delete_syncs_data', { cause: err instanceof Error ? err.message : err });
-            errorManager.report(e, { source: ErrorSourceEnum.PLATFORM });
-        }
-        metrics.duration(metrics.Types.JOBS_DELETE_SYNCS_DATA, Date.now() - start);
-    });
-}
-
-export async function exec(): Promise<void> {
-    logger.info('[deleteSyncs] starting');
-
-    // Because it's slow and create deadlocks
-    // we need to acquire a Lock that prevents any other duplicate cron to execute the same thing
-    const { rows } = await db.knex.raw<{ rows: { delete_syncs: boolean }[] }>(`SELECT pg_try_advisory_xact_lock(?) as delete_syncs`, [123456789]);
-    if (!rows || rows.length <= 0 || !rows[0]!.delete_syncs) {
-        logger.info(`[deleteSyncs] could not acquire lock, skipping`);
-        return;
-    }
-
-    const syncs = await findRecentlyDeletedSync();
-
-    const orchestrator = new Orchestrator(orchestratorClient);
-
-    for (const sync of syncs) {
-        logger.info(`[deleteSyncs] deleting syncId: ${sync.id}`);
-
-        // hard delete jobs
-        let countJobs = 0;
-        do {
-            countJobs = await hardDeleteJobs({ syncId: sync.id, limit: limitJobs });
-            logger.info(`[deleteSyncs] hard deleted ${countJobs} jobs`);
-            metrics.increment(metrics.Types.JOBS_DELETE_SYNCS_DATA_JOBS, countJobs);
-        } while (countJobs >= limitJobs);
-
-        // -----
-        // Soft delete schedules
-        const resSchedule = await orchestrator.deleteSync({ syncId: sync.id, environmentId: sync.environmentId });
-        const deletedScheduleCount = resSchedule.isErr() ? 1 : 0;
-        logger.info(`[deleteSyncs] soft deleted ${deletedScheduleCount} schedules`);
-        metrics.increment(metrics.Types.JOBS_DELETE_SYNCS_DATA_SCHEDULES, deletedScheduleCount);
-
-        // ----
-        // hard delete records
-        let deletedRecords = 0;
-        for (const model of sync.models) {
-            const res = await records.deleteRecordsBySyncId({
-                connectionId: sync.connectionId,
-                environmentId: sync.environmentId,
-                model,
-                syncId: sync.id,
-                limit: limitRecords
-            });
-            deletedRecords += res.totalDeletedRecords;
-        }
-        metrics.increment(metrics.Types.JOBS_DELETE_SYNCS_DATA_RECORDS, deletedRecords);
-    }
-}
diff --git a/packages/jobs/package.json b/packages/jobs/package.json
index 30ca042a16..32deea08a7 100644
--- a/packages/jobs/package.json
+++ b/packages/jobs/package.json
@@ -30,7 +30,6 @@
         "dd-trace": "5.21.0",
         "get-port": "7.1.0",
         "express": "4.20.0",
-        "node-cron": "3.0.3",
         "rate-limiter-flexible": "5.0.3",
         "redis": "4.6.13",
         "zod": "3.24.1"
diff --git a/packages/records/lib/models/records.ts b/packages/records/lib/models/records.ts
index d4697ea933..2dcfc52d9a 100644
--- a/packages/records/lib/models/records.ts
+++ b/packages/records/lib/models/records.ts
@@ -597,7 +597,7 @@ export async function deleteRecordsBySyncId({
             .from(RECORDS_TABLE)
             .where({ connection_id: connectionId, model })
             .whereIn('id', function (sub) {
-                void sub.select('id').from(RECORDS_TABLE).where({ connection_id: connectionId, model, sync_id: syncId }).limit(limit);
+                sub.select('id').from(RECORDS_TABLE).where({ connection_id: connectionId, model, sync_id: syncId }).limit(limit);
             })
             .del();
         totalDeletedRecords += deletedRecords;
diff --git a/packages/server/lib/crons/deleteOldData.ts b/packages/server/lib/crons/deleteOldData.ts
index 1057c849f2..6f246be2da 100644
--- a/packages/server/lib/crons/deleteOldData.ts
+++ b/packages/server/lib/crons/deleteOldData.ts
@@ -6,13 +6,26 @@ import * as cron from 'node-cron';
 import db from '@nangohq/database';
 import { deleteExpiredPrivateKeys } from '@nangohq/keystore';
 import { getLocking } from '@nangohq/kvstore';
-import { deleteJobsByDate } from '@nangohq/shared';
+import { records } from '@nangohq/records';
+import {
+    configService,
+    connectionService,
+    deleteExpiredInvitations,
+    deleteJobsByDate,
+    getSoftDeletedSyncConfig,
+    hardDeleteEndpoints,
+    hardDeleteSync,
+    hardDeleteSyncConfig
+} from '@nangohq/shared';
 import { getLogger, metrics, report } from '@nangohq/utils';
 
 import { envs } from '../env.js';
 import { deleteExpiredConnectSession } from '../services/connectSession.service.js';
+import oauthSessionService from '../services/oauth-session.service.js';
 
 import type { Lock } from '@nangohq/kvstore';
+import type { Sync } from '@nangohq/shared';
+import type { DBSyncConfig } from '@nangohq/types';
 
 const logger = getLogger('cron.deleteOldData');
 
@@ -23,6 +36,11 @@ const deleteJobsOlderThan = envs.CRON_DELETE_OLD_JOBS_MAX_DAYS;
 
 const deleteConnectionSessionOlderThan = envs.CRON_DELETE_OLD_CONNECT_SESSION_MAX_DAYS;
 const deletePrivateKeysOlderThan = envs.CRON_DELETE_OLD_PRIVATE_KEYS_MAX_DAYS;
+const deleteOauthSessionOlderThan = envs.CRON_DELETE_OLD_OAUTH_SESSION_MAX_DAYS;
+const deleteInvitationsOlderThan = envs.CRON_DELETE_OLD_INVITATIONS_MAX_DAYS;
+const deleteConfigsOlderThan = envs.CRON_DELETE_OLD_CONFIGS_MAX_DAYS;
+const deleteSyncConfigsOlderThan = envs.CRON_DELETE_OLD_SYNC_CONFIGS_MAX_DAYS;
+const deleteConnectionsOlderThan = envs.CRON_DELETE_OLD_CONNECTIONS_MAX_DAYS;
 
 export function deleteOldData(): void {
     if (envs.CRON_DELETE_OLD_DATA_EVERY_MIN <= 0) {
@@ -53,62 +71,205 @@ export async function exec(): Promise<void> {
 
     logger.info(`Starting`);
 
-    const ttlMs = (cronMinutes - 1) * 60 * 1000;
-    const startTimestamp = Date.now();
+    const ttlMs = cronMinutes * 60 * 1000 - 1000;
     const lockKey = `lock:deleteOldData:cron`;
+    const deadline = new Date(Date.now() + ttlMs);
     let lock: Lock | undefined;
     try {
         try {
             lock = await locking.acquire(lockKey, ttlMs);
-        } catch {
-            logger.info(`Could not acquire lock, skipping`);
+        } catch (err) {
+            logger.info(`Could not acquire lock, skipping`, err);
             return;
         }
 
         // Delete jobs
-        while (true) {
-            const deleted = await deleteJobsByDate({ olderThan: deleteJobsOlderThan, limit });
-            logger.info(`Deleted ${deleted} jobs`);
-            if (deleted < limit) {
-                break;
-            }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
-            }
-            await setTimeout(1000);
-        }
+        await batchDelete({
+            name: 'jobs',
+            deadline,
+            deleteFn: async () => await deleteJobsByDate({ olderThan: deleteJobsOlderThan, limit })
+        });
 
         // Delete connect session
-        while (true) {
-            const deleted = await deleteExpiredConnectSession(db.knex, { olderThan: deleteConnectionSessionOlderThan, limit });
-            logger.info(`Deleted ${deleted} connect session`);
-            if (deleted < limit) {
-                break;
-            }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
-            }
-            await setTimeout(1000);
-        }
+        await batchDelete({
+            name: 'connect session',
+            deadline,
+            deleteFn: async () => await deleteExpiredConnectSession(db.knex, { olderThan: deleteConnectionSessionOlderThan, limit })
+        });
 
         // Delete private keys
-        while (true) {
-            const deleted = await deleteExpiredPrivateKeys(db.knex, { olderThan: deletePrivateKeysOlderThan, limit });
-            logger.info(`Deleted ${deleted} private keys`);
-            if (deleted < limit) {
-                break;
+        await batchDelete({
+            name: 'private keys',
+            deadline,
+            deleteFn: async () => await deleteExpiredPrivateKeys(db.knex, { olderThan: deletePrivateKeysOlderThan, limit })
+        });
+
+        // Delete oauth sessions
+        await batchDelete({
+            name: 'oauth sessions',
+            deadline,
+            deleteFn: async () => await oauthSessionService.deleteExpiredSessions({ limit, olderThan: deleteOauthSessionOlderThan })
+        });
+
+        // Delete invitations
+        await batchDelete({
+            name: 'invitations',
+            deadline,
+            deleteFn: async () => await deleteExpiredInvitations({ limit, olderThan: deleteInvitationsOlderThan })
+        });
+
+        // Delete integrations and all associated data
+        await batchDelete({
+            name: 'integration',
+            deadline,
+            deleteFn: async () => {
+                const integrations = await configService.getSoftDeleted({ limit, olderThan: deleteConfigsOlderThan });
+                for (const integration of integrations) {
+                    logger.info('Deleting integration...', integration.id, integration.unique_key);
+
+                    await batchDelete({
+                        name: 'sync configs < integration',
+                        deadline,
+                        deleteFn: async () => {
+                            const syncsConfigs = await db.knex
+                                .from<DBSyncConfig>('_nango_sync_configs')
+                                .select<DBSyncConfig[]>()
+                                .where({ nango_config_id: integration.id! })
+                                .limit(limit);
+
+                            for (const syncConfig of syncsConfigs) {
+                                await deleteSyncConfigData({ syncConfig, deadline });
+                            }
+
+                            return syncsConfigs.length;
+                        }
+                    });
+
+                    // Delete connections
+                    await batchDelete({
+                        name: 'connections < integration',
+                        deadline,
+                        deleteFn: async () => await connectionService.hardDeleteByIntegration({ limit, integrationId: integration.id! })
+                    });
+
+                    await configService.hardDelete(integration.id!);
+                }
+
+                return integrations.length;
+            }
+        });
+
+        // Delete sync configs and all associated data
+        await batchDelete({
+            name: 'sync configs',
+            deadline,
+            deleteFn: async () => {
+                const syncsConfigs = await getSoftDeletedSyncConfig({ limit, olderThan: deleteSyncConfigsOlderThan });
+
+                for (const syncConfig of syncsConfigs) {
+                    await deleteSyncConfigData({ syncConfig, deadline });
+                }
+
+                return syncsConfigs.length;
             }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
+        });
+
+        // Delete connections and all associated data
+        await batchDelete({
+            name: 'connections',
+            deadline,
+            deleteFn: async () => {
+                const connections = await connectionService.getSoftDeleted({ limit, olderThan: deleteConnectionsOlderThan });
+
+                for (const connection of connections) {
+                    logger.info('Deleting connection...', connection.id, connection.connection_id);
+                    const resSyncs = await db.knex
+                        .select<
+                            {
+                                sync: Sync;
+                                syncConfig: DBSyncConfig;
+                            }[]
+                        >(db.knex.raw('row_to_json(_nango_syncs.*) as sync'), db.knex.raw('row_to_json(_nango_sync_configs.*) as "syncConfig"'))
+                        .from<Sync>('_nango_syncs')
+                        .join('_nango_sync_configs', '_nango_sync_configs.id', '_nango_syncs.sync_config_id')
+                        .where({ nango_connection_id: connection.id });
+                    for (const res of resSyncs) {
+                        await deleteSyncData({ ...res });
+                    }
+
+                    await connectionService.hardDelete(connection.id);
+                }
+
+                return connections.length;
             }
-            await setTimeout(1000);
-        }
+        });
     } finally {
         if (lock) {
             locking.release(lock);
         }
     }
 }
+
+async function batchDelete({ name, deleteFn, deadline }: { name: string; deleteFn: () => Promise<number>; deadline: Date }) {
+    while (true) {
+        const deleted = await deleteFn();
+        if (deleted) {
+            logger.info(`Deleted ${deleted} ${name}`);
+        }
+        if (deleted < limit) {
+            break;
+        }
+        if (Date.now() > deadline.getTime()) {
+            logger.info(`Time limit reached, stopping`);
+            return;
+        }
+        await setTimeout(1000);
+    }
+}
+
+async function deleteSyncConfigData({ syncConfig, deadline }: { syncConfig: DBSyncConfig; deadline: Date }) {
+    logger.info('Deleting sync config...', syncConfig.id, syncConfig.sync_name);
+
+    await batchDelete({
+        name: 'syncs',
+        deadline,
+        deleteFn: async () => {
+            const syncs = await db.knex.from<Sync>('_nango_syncs').select<Sync[]>().where({ sync_config_id: syncConfig.id }).limit(limit);
+
+            for (const sync of syncs) {
+                await deleteSyncData({ sync, syncConfig });
+            }
+
+            return syncs.length;
+        }
+    });
+
+    const delEndpoints = await hardDeleteEndpoints({ syncConfigId: syncConfig.id });
+    if (delEndpoints) {
+        logger.info('deleted', delEndpoints, 'endpoints');
+    }
+
+    // delete sync_config
+    await hardDeleteSyncConfig(syncConfig.id);
+}
+
+async function deleteSyncData({ sync, syncConfig }: { sync: Sync; syncConfig: DBSyncConfig }) {
+    logger.info('Deleting sync...', sync.id, sync.name);
+
+    for (const model of syncConfig.models) {
+        // delete records for each model
+        const res = await records.deleteRecordsBySyncId({
+            connectionId: sync.nango_connection_id,
+            environmentId: syncConfig.environment_id,
+            model,
+            syncId: sync.id,
+            limit: limit
+        });
+        if (res.totalDeletedRecords) {
+            logger.info('deleted', res.totalDeletedRecords, 'records for model', model);
+        }
+    }
+
+    // delete sync
+    await hardDeleteSync(sync.id);
+}
diff --git a/packages/server/lib/server.ts b/packages/server/lib/server.ts
index 3d92265db3..393dc77c34 100644
--- a/packages/server/lib/server.ts
+++ b/packages/server/lib/server.ts
@@ -23,7 +23,6 @@ import { timeoutLogsOperations } from './crons/timeoutLogsOperations.js';
 import { envs } from './env.js';
 import { runnersFleet } from './fleet.js';
 import { router } from './routes.js';
-import oAuthSessionService from './services/oauth-session.service.js';
 import migrate from './utils/migrate.js';
 
 import type { WebSocket } from 'ws';
@@ -86,7 +85,6 @@ if (NANGO_MIGRATE_AT_START === 'true') {
 // Preload providers
 getProviders();
 
-await oAuthSessionService.clearStaleSessions();
 refreshConnectionsCron();
 exportUsageMetricsCron();
 timeoutLogsOperations();
diff --git a/packages/server/lib/services/connectSession.service.ts b/packages/server/lib/services/connectSession.service.ts
index cd70923fe8..84d4e1b535 100644
--- a/packages/server/lib/services/connectSession.service.ts
+++ b/packages/server/lib/services/connectSession.service.ts
@@ -181,7 +181,7 @@ export async function deleteExpiredConnectSession(db: Knex, { limit, olderThan }
     return await db
         .from<DBConnectSession>(CONNECT_SESSIONS_TABLE)
         .whereIn('id', function (sub) {
-            sub.select('id').where('created_at', '<=', dateThreshold.toISOString()).limit(limit);
+            sub.select('id').from<DBConnectSession>(CONNECT_SESSIONS_TABLE).where('created_at', '<=', dateThreshold.toISOString()).limit(limit);
         })
         .delete();
 }
diff --git a/packages/server/lib/services/oauth-session.service.ts b/packages/server/lib/services/oauth-session.service.ts
index c846a725dc..7e11a937e7 100644
--- a/packages/server/lib/services/oauth-session.service.ts
+++ b/packages/server/lib/services/oauth-session.service.ts
@@ -22,15 +22,20 @@ class OAuthSessionService {
     }
 
     /**
-     * This will clear the sessions that have been created for more than 24hrs,
+     * This will clear the sessions that have been created for more than "olderThan"
      * it's possible that some sessions are created but at the end the callback url
      * was not called hence the sessions still remains.
-     * We will use the method to clean such for now its cleans in the last 24hrs
      */
-    async clearStaleSessions() {
-        const currentTime = new Date().getTime();
-        const time24HoursAgo = new Date(currentTime - 24 * 60 * 60 * 1000);
-        return this.queryBuilder().where('created_at', '<', time24HoursAgo).delete();
+    async deleteExpiredSessions({ limit, olderThan }: { limit: number; olderThan: number }): Promise<number> {
+        const dateThreshold = new Date();
+        dateThreshold.setDate(dateThreshold.getDate() - olderThan);
+
+        return await db.knex
+            .from<OAuthSession>('_nango_oauth_sessions')
+            .whereIn('id', function (sub) {
+                sub.select('id').from<OAuthSession>('_nango_oauth_sessions').where('created_at', '<=', dateThreshold.toISOString()).limit(limit);
+            })
+            .delete();
     }
 
     private queryBuilder() {
diff --git a/packages/shared/lib/services/config.service.ts b/packages/shared/lib/services/config.service.ts
index 60dfa21cd8..905115b93f 100644
--- a/packages/shared/lib/services/config.service.ts
+++ b/packages/shared/lib/services/config.service.ts
@@ -120,12 +120,14 @@ class ConfigService {
         providerConfigKey: string;
         orchestrator: Orchestrator;
     }): Promise<boolean> {
+        // TODO: might be useless since we are dropping the data after a while
         await syncManager.deleteSyncsByProviderConfig(environmentId, providerConfigKey, orchestrator);
 
         if (isCloud) {
             await deleteSyncFilesForConfig(id, environmentId);
         }
 
+        // TODO: might be useless since we are dropping the data after a while
         await deleteSyncConfigByConfigId(id);
 
         const updated = await db.knex.from<ProviderConfig>(`_nango_configs`).where({ id, deleted: false }).update({ deleted: true, deleted_at: new Date() });
@@ -133,6 +135,7 @@ class ConfigService {
             return false;
         }
 
+        // TODO: might be useless since we are dropping the data after a while
         await db.knex
             .from<DBConnection>(`_nango_connections`)
             .where({ provider_config_key: providerConfigKey, environment_id: environmentId, deleted: false })
@@ -201,6 +204,22 @@ class ConfigService {
         return { copiedToId: providerConfigResponse.id!, copiedFromId: foundConfigId };
     }
 
+    async getSoftDeleted({ limit, olderThan }: { limit: number; olderThan: number }): Promise<IntegrationConfig[]> {
+        const dateThreshold = new Date();
+        dateThreshold.setDate(dateThreshold.getDate() - olderThan);
+
+        return await db.knex
+            .select('*')
+            .from<IntegrationConfig>(`_nango_configs`)
+            .where('deleted', true)
+            .andWhere('deleted_at', '<=', dateThreshold.toISOString())
+            .limit(limit);
+    }
+
+    async hardDelete(id: number): Promise<void> {
+        await db.knex.from<ProviderConfig>(`_nango_configs`).where({ id }).delete();
+    }
+
     VALIDATION_RULES: ValidationRule[] = [
         {
             field: 'oauth_client_id',
diff --git a/packages/shared/lib/services/connection.service.ts b/packages/shared/lib/services/connection.service.ts
index 0a7f685a10..b708b59f29 100644
--- a/packages/shared/lib/services/connection.service.ts
+++ b/packages/shared/lib/services/connection.service.ts
@@ -10,12 +10,12 @@ import configService from './config.service.js';
 import { getFreshOAuth2Credentials } from '../clients/oauth2.client.js';
 import providerClient from '../clients/provider.client.js';
 import { CONNECTIONS_WITH_SCRIPTS_CAP_LIMIT } from '../constants.js';
-import analytics, { AnalyticsTypes } from '../utils/analytics.js';
-import { DEFAULT_BILL_EXPIRES_AT_MS, DEFAULT_OAUTHCC_EXPIRES_AT_MS, MAX_FAILED_REFRESH, getExpiresAtFromCredentials } from './connections/utils.js';
-import syncManager from './sync/manager.service.js';
 import environmentService from '../services/environment.service.js';
 import { generateWsseSignature } from '../signatures/wsse.signature.js';
+import analytics, { AnalyticsTypes } from '../utils/analytics.js';
 import encryptionManager from '../utils/encryption.manager.js';
+import { DEFAULT_BILL_EXPIRES_AT_MS, DEFAULT_OAUTHCC_EXPIRES_AT_MS, MAX_FAILED_REFRESH, getExpiresAtFromCredentials } from './connections/utils.js';
+import syncManager from './sync/manager.service.js';
 import { NangoError } from '../utils/error.js';
 import {
     extractStepNumber,
@@ -834,6 +834,8 @@ class ConnectionService {
             })
             .update({ deleted: true, credentials: {}, credentials_iv: null, credentials_tag: null, deleted_at: new Date() });
 
+        // TODO: might be useless since we are dropping the data after a while
+        await syncManager.softDeleteSyncsByConnection(connection, orchestrator);
         // TODO: move the following side effects to a post deletion hook
         // so we can remove the orchestrator dependencies
         await syncManager.softDeleteSyncsByConnection(connection, orchestrator);
@@ -1787,6 +1789,31 @@ class ConnectionService {
 
         return Err(new NangoError('failed_to_get_connections_count'));
     }
+
+    async getSoftDeleted({ limit, olderThan }: { limit: number; olderThan: number }): Promise<DBConnection[]> {
+        const dateThreshold = new Date();
+        dateThreshold.setDate(dateThreshold.getDate() - olderThan);
+
+        return await db.knex
+            .select('*')
+            .from<DBConnection>(`_nango_connections`)
+            .where('deleted', true)
+            .andWhere('deleted_at', '<=', dateThreshold.toISOString())
+            .limit(limit);
+    }
+
+    async hardDeleteByIntegration({ integrationId, limit }: { integrationId: number; limit: number }): Promise<number> {
+        return await db.knex
+            .from<DBConnection>('_nango_connections')
+            .whereIn('id', function (sub) {
+                sub.select('id').from<DBConnection>('_nango_connections').where('config_id', integrationId).limit(limit);
+            })
+            .delete();
+    }
+
+    async hardDelete(id: number): Promise<number> {
+        return await db.knex.from<DBConnection>('_nango_connections').where('id', id).delete();
+    }
 }
 
 export default new ConnectionService();
diff --git a/packages/shared/lib/services/invitations.ts b/packages/shared/lib/services/invitations.ts
index b9c7fdadff..92afcd955c 100644
--- a/packages/shared/lib/services/invitations.ts
+++ b/packages/shared/lib/services/invitations.ts
@@ -106,3 +106,15 @@ export async function getInvitation(token: string): Promise<DBInvitation | null>
 
     return result || null;
 }
+
+export async function deleteExpiredInvitations({ limit, olderThan }: { limit: number; olderThan: number }): Promise<number> {
+    const dateThreshold = new Date();
+    dateThreshold.setDate(dateThreshold.getDate() - olderThan);
+
+    return await db.knex
+        .from<DBInvitation>('_nango_invited_users')
+        .whereIn('id', function (sub) {
+            sub.select('id').from<DBInvitation>('_nango_invited_users').where('expires_at', '<=', dateThreshold.toISOString()).limit(limit);
+        })
+        .delete();
+}
diff --git a/packages/shared/lib/services/sync/config/config.service.ts b/packages/shared/lib/services/sync/config/config.service.ts
index da49655257..affbdb4d8a 100644
--- a/packages/shared/lib/services/sync/config/config.service.ts
+++ b/packages/shared/lib/services/sync/config/config.service.ts
@@ -745,3 +745,19 @@ export async function getSyncConfigRaw(opts: { environmentId: number; config_id:
 
     return res || null;
 }
+
+export async function getSoftDeletedSyncConfig({ limit, olderThan }: { limit: number; olderThan: number }): Promise<DBSyncConfig[]> {
+    const dateThreshold = new Date();
+    dateThreshold.setDate(dateThreshold.getDate() - olderThan);
+
+    return await db.knex
+        .select('*')
+        .from<DBSyncConfig>(`_nango_sync_configs`)
+        .where('deleted', true)
+        .andWhere('deleted_at', '<=', dateThreshold.toISOString())
+        .limit(limit);
+}
+
+export async function hardDeleteSyncConfig(id: number) {
+    await db.knex.from<DBSyncConfig>('_nango_sync_configs').where({ id }).delete();
+}
diff --git a/packages/shared/lib/services/sync/config/endpoint.service.ts b/packages/shared/lib/services/sync/config/endpoint.service.ts
index a31fb4e0d1..6f0d357ef4 100644
--- a/packages/shared/lib/services/sync/config/endpoint.service.ts
+++ b/packages/shared/lib/services/sync/config/endpoint.service.ts
@@ -1,7 +1,7 @@
-import { schema, dbNamespace } from '@nangohq/database';
+import db, { schema, dbNamespace } from '@nangohq/database';
 import configService from '../../config.service.js';
 import type { HTTP_METHOD } from '../../../models/Generic.js';
-import type { DBConnection, DBConnectionDecrypted, DBSyncConfig } from '@nangohq/types';
+import type { DBConnection, DBConnectionDecrypted, DBSyncConfig, DBSyncEndpoint } from '@nangohq/types';
 
 const ENDPOINT_TABLE = dbNamespace + 'sync_endpoints';
 const SYNC_CONFIG_TABLE = dbNamespace + 'sync_configs';
@@ -40,3 +40,7 @@ export async function getActionOrModelByEndpoint(connection: DBConnection | DBCo
         return { model: result['model'] };
     }
 }
+
+export async function hardDeleteEndpoints({ syncConfigId }: { syncConfigId: number }): Promise<number> {
+    return await db.knex.from<DBSyncEndpoint>('_nango_sync_endpoints').where({ sync_config_id: syncConfigId }).delete();
+}
diff --git a/packages/shared/lib/services/sync/sync.service.ts b/packages/shared/lib/services/sync/sync.service.ts
index 5b4e0a8a12..aa290236da 100644
--- a/packages/shared/lib/services/sync/sync.service.ts
+++ b/packages/shared/lib/services/sync/sync.service.ts
@@ -529,18 +529,10 @@ export const getAndReconcileDifferences = async ({
     };
 };
 
-export async function findRecentlyDeletedSync(): Promise<{ id: string; environmentId: number; connectionId: number; models: string[] }[]> {
-    const q = db.readOnly
-        .from('_nango_syncs')
-        .select<
-            { id: string; environmentId: number; connectionId: number; models: string[] }[]
-        >('_nango_syncs.id as id', '_nango_connections.environment_id as environmentId', '_nango_connections.id as connectionId', '_nango_sync_configs.models as models')
-        .join('_nango_connections', '_nango_connections.id', '_nango_syncs.nango_connection_id')
-        .join('_nango_sync_configs', '_nango_sync_configs.id', '_nango_syncs.sync_config_id')
-        .where(db.knex.raw("_nango_syncs.deleted_at >  NOW() - INTERVAL '6h'"));
-    return await q;
-}
-
 export async function trackFetch(nango_connection_id: number): Promise<void> {
     await db.knex.from<Sync>(`_nango_syncs`).where({ nango_connection_id, deleted: false }).update({ last_fetched_at: new Date() });
 }
+
+export async function hardDeleteSync(id: string) {
+    await db.knex.from<Sync>('_nango_syncs').where({ id }).delete();
+}
diff --git a/packages/utils/lib/environment/parse.ts b/packages/utils/lib/environment/parse.ts
index 5dd2cd63ab..f00851a4ba 100644
--- a/packages/utils/lib/environment/parse.ts
+++ b/packages/utils/lib/environment/parse.ts
@@ -43,6 +43,11 @@ export const ENVS = z.object({
     CRON_DELETE_OLD_JOBS_MAX_DAYS: z.coerce.number().optional().default(31),
     CRON_DELETE_OLD_CONNECT_SESSION_MAX_DAYS: z.coerce.number().optional().default(31),
     CRON_DELETE_OLD_PRIVATE_KEYS_MAX_DAYS: z.coerce.number().optional().default(31),
+    CRON_DELETE_OLD_OAUTH_SESSION_MAX_DAYS: z.coerce.number().optional().default(2),
+    CRON_DELETE_OLD_INVITATIONS_MAX_DAYS: z.coerce.number().optional().default(2),
+    CRON_DELETE_OLD_CONFIGS_MAX_DAYS: z.coerce.number().optional().default(31),
+    CRON_DELETE_OLD_SYNC_CONFIGS_MAX_DAYS: z.coerce.number().optional().default(31),
+    CRON_DELETE_OLD_CONNECTIONS_MAX_DAYS: z.coerce.number().optional().default(31),
     CRON_REFRESH_CONNECTIONS_EVERY_MIN: z.coerce.number().optional().default(10),
     CRON_REFRESH_CONNECTIONS_LIMIT: z.coerce.number().optional().default(100),
 


Reviews:
user: login: TBonnin
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-17T17:57:42Z
user: login: bodinsamuel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T13:16:37Z
user: login: bodinsamuel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T13:19:53Z
user: login: bodinsamuel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-18T13:22:21Z
user: login: TBonnin
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-18T13:34:36Z
user: login: TBonnin
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-18T13:36:11Z

Review comments:
diff_hunk: @@ -50,62 +70,226 @@ export async function exec(): Promise<void> {
 
     logger.info(`Starting`);
 
-    const ttlMs = (cronMinutes - 1) * 60 * 1000;
+    const ttlMs = cronMinutes * 60 * 1000 - 1000;
     const startTimestamp = Date.now();
     const lockKey = `lock:deleteOldData:cron`;
     let lock: Lock | undefined;
     try {
         try {
             lock = await locking.acquire(lockKey, ttlMs);
-        } catch {
-            logger.info(`Could not acquire lock, skipping`);
+        } catch (err) {
+            logger.info(`Could not acquire lock, skipping`, err);
             return;
         }
 
         // Delete jobs
-        while (true) {
-            const deleted = await deleteJobsByDate({ olderThan: deleteJobsOlderThan, limit });
-            logger.info(`Deleted ${deleted} jobs`);
-            if (deleted < limit) {
-                break;
-            }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
-            }
-            await setTimeout(1000);
-        }
+        await batchDelete({
+            name: 'jobs',
+            startTimestamp,
+            ttlMs,
path: packages/server/lib/crons/deleteOldData.ts
user: login: TBonnin
body: We are passing the same `ttlMs` to all batchDelete call but if the first one take 2mins the following one should have less time to execute. I would pass a `deadline` date instead?
created_at: 2025-03-17T17:47:31Z
author_association: COLLABORATOR
diff_hunk: @@ -50,62 +70,226 @@ export async function exec(): Promise<void> {
 
     logger.info(`Starting`);
 
-    const ttlMs = (cronMinutes - 1) * 60 * 1000;
+    const ttlMs = cronMinutes * 60 * 1000 - 1000;
     const startTimestamp = Date.now();
     const lockKey = `lock:deleteOldData:cron`;
     let lock: Lock | undefined;
     try {
         try {
             lock = await locking.acquire(lockKey, ttlMs);
-        } catch {
-            logger.info(`Could not acquire lock, skipping`);
+        } catch (err) {
+            logger.info(`Could not acquire lock, skipping`, err);
             return;
         }
 
         // Delete jobs
-        while (true) {
-            const deleted = await deleteJobsByDate({ olderThan: deleteJobsOlderThan, limit });
-            logger.info(`Deleted ${deleted} jobs`);
-            if (deleted < limit) {
-                break;
-            }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
-            }
-            await setTimeout(1000);
-        }
+        await batchDelete({
+            name: 'jobs',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteJobsByDate({ olderThan: deleteJobsOlderThan, limit })
+        });
 
         // Delete connect session
-        while (true) {
-            const deleted = await deleteExpiredConnectSession(db.knex, { olderThan: deleteConnectionSessionOlderThan, limit });
-            logger.info(`Deleted ${deleted} connect session`);
-            if (deleted < limit) {
-                break;
-            }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
-            }
-            await setTimeout(1000);
-        }
+        await batchDelete({
+            name: 'connect session',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredConnectSession(db.knex, { olderThan: deleteConnectionSessionOlderThan, limit })
+        });
 
         // Delete private keys
-        while (true) {
-            const deleted = await deleteExpiredPrivateKeys(db.knex, { olderThan: deletePrivateKeysOlderThan, limit });
-            logger.info(`Deleted ${deleted} private keys`);
-            if (deleted < limit) {
-                break;
+        await batchDelete({
+            name: 'private keys',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredPrivateKeys(db.knex, { olderThan: deletePrivateKeysOlderThan, limit })
+        });
+
+        // Delete oauth sessions
+        await batchDelete({
+            name: 'oauth sessions',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await oauthSessionService.deleteExpiredSessions({ limit, olderThan: deleteOauthSessionOlderThan })
+        });
+
+        // Delete invitations
+        await batchDelete({
+            name: 'invitations',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredInvitations({ limit, olderThan: deleteInvitationsOlderThan })
+        });
+
+        // Delete integrations and all associated data
+        await batchDelete({
+            name: 'integration',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const integrations = await configService.getSoftDeleted({ limit, olderThan: deleteConfigsOlderThan });
+                for (const integration of integrations) {
+                    logger.info('Deleting integration...', integration.id, integration.unique_key);
+
+                    await batchDelete({
+                        name: 'sync configs < integration',
+                        startTimestamp,
+                        ttlMs,
+                        deleteFn: async () => {
+                            const syncsConfigs = await db.knex
+                                .from<DBSyncConfig>('_nango_sync_configs')
+                                .select<DBSyncConfig[]>()
+                                .where({ nango_config_id: integration.id! })
+                                .limit(limit);
+
+                            for (const syncConfig of syncsConfigs) {
+                                await deleteSyncConfigData({ syncConfig, startTimestamp, ttlMs });
+                            }
+
+                            return syncsConfigs.length;
+                        }
+                    });
+
+                    // Delete connections
+                    await batchDelete({
+                        name: 'connections < integration',
+                        startTimestamp,
+                        ttlMs,
+                        deleteFn: async () => await connectionService.hardDeleteByIntegration({ limit, integrationId: integration.id! })
+                    });
+
+                    await configService.hardDelete(integration.id!);
+                }
+
+                return integrations.length;
+            }
+        });
+
+        // Delete sync configs and all associated data
+        await batchDelete({
+            name: 'sync configs',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const syncsConfigs = await getSoftDeletedSyncConfig({ limit, olderThan: deleteSyncConfigsOlderThan });
+
+                for (const syncConfig of syncsConfigs) {
+                    await deleteSyncConfigData({ syncConfig, startTimestamp, ttlMs });
+                }
+
+                return syncsConfigs.length;
             }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
+        });
+
+        // Delete connections and all associated data
+        await batchDelete({
+            name: 'connections',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const connections = await connectionService.getSoftDeleted({ limit, olderThan: deleteConnectionsOlderThan });
+
+                for (const connection of connections) {
+                    logger.info('Deleting connection...', connection.id, connection.connection_id);
+                    const resSyncs = await db.knex
+                        .select<
+                            {
+                                sync: Sync;
+                                syncConfig: DBSyncConfig;
+                            }[]
+                        >(db.knex.raw('row_to_json(_nango_syncs.*) as sync'), db.knex.raw('row_to_json(_nango_sync_configs.*) as "syncConfig"'))
+                        .from<Sync>('_nango_syncs')
+                        .join('_nango_sync_configs', '_nango_sync_configs.id', '_nango_syncs.sync_config_id')
+                        .where({ nango_connection_id: connection.id });
+                    for (const res of resSyncs) {
+                        await deleteSyncData({ ...res });
+                    }
+
+                    await connectionService.hardDelete(connection.id);
+                }
+
+                return connections.length;
             }
-            await setTimeout(1000);
-        }
+        });
     } finally {
         if (lock) {
             locking.release(lock);
         }
     }
 }
+
+async function batchDelete({
+    name,
+    deleteFn,
+    startTimestamp,
+    ttlMs
+}: {
+    name: string;
+    deleteFn: () => Promise<number>;
+    startTimestamp: number;
+    ttlMs: number;
+}) {
+    while (true) {
+        const deleted = await deleteFn();
+        if (deleted) {
+            logger.info(`Deleted ${deleted} ${name}`);
+        }
+        if (deleted < limit) {
+            break;
+        }
+        if (Date.now() - startTimestamp > ttlMs) {
+            logger.info(`Time limit reached, stopping`);
+            return;
+        }
+        await setTimeout(1000);
+    }
+}
path: packages/server/lib/crons/deleteOldData.ts
user: login: TBonnin
body: should `deleteFn` be passed a transaction? If the function executes multiples queries and one fails, we would end up with a partial deletion
created_at: 2025-03-17T17:51:28Z
author_association: COLLABORATOR
diff_hunk: @@ -50,62 +70,226 @@ export async function exec(): Promise<void> {
 
     logger.info(`Starting`);
 
-    const ttlMs = (cronMinutes - 1) * 60 * 1000;
+    const ttlMs = cronMinutes * 60 * 1000 - 1000;
     const startTimestamp = Date.now();
     const lockKey = `lock:deleteOldData:cron`;
     let lock: Lock | undefined;
     try {
         try {
             lock = await locking.acquire(lockKey, ttlMs);
-        } catch {
-            logger.info(`Could not acquire lock, skipping`);
+        } catch (err) {
+            logger.info(`Could not acquire lock, skipping`, err);
             return;
         }
 
         // Delete jobs
-        while (true) {
-            const deleted = await deleteJobsByDate({ olderThan: deleteJobsOlderThan, limit });
-            logger.info(`Deleted ${deleted} jobs`);
-            if (deleted < limit) {
-                break;
-            }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
-            }
-            await setTimeout(1000);
-        }
+        await batchDelete({
+            name: 'jobs',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteJobsByDate({ olderThan: deleteJobsOlderThan, limit })
+        });
 
         // Delete connect session
-        while (true) {
-            const deleted = await deleteExpiredConnectSession(db.knex, { olderThan: deleteConnectionSessionOlderThan, limit });
-            logger.info(`Deleted ${deleted} connect session`);
-            if (deleted < limit) {
-                break;
-            }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
-            }
-            await setTimeout(1000);
-        }
+        await batchDelete({
+            name: 'connect session',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredConnectSession(db.knex, { olderThan: deleteConnectionSessionOlderThan, limit })
+        });
 
         // Delete private keys
-        while (true) {
-            const deleted = await deleteExpiredPrivateKeys(db.knex, { olderThan: deletePrivateKeysOlderThan, limit });
-            logger.info(`Deleted ${deleted} private keys`);
-            if (deleted < limit) {
-                break;
+        await batchDelete({
+            name: 'private keys',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredPrivateKeys(db.knex, { olderThan: deletePrivateKeysOlderThan, limit })
+        });
+
+        // Delete oauth sessions
+        await batchDelete({
+            name: 'oauth sessions',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await oauthSessionService.deleteExpiredSessions({ limit, olderThan: deleteOauthSessionOlderThan })
+        });
+
+        // Delete invitations
+        await batchDelete({
+            name: 'invitations',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredInvitations({ limit, olderThan: deleteInvitationsOlderThan })
+        });
+
+        // Delete integrations and all associated data
+        await batchDelete({
+            name: 'integration',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const integrations = await configService.getSoftDeleted({ limit, olderThan: deleteConfigsOlderThan });
+                for (const integration of integrations) {
+                    logger.info('Deleting integration...', integration.id, integration.unique_key);
+
+                    await batchDelete({
+                        name: 'sync configs < integration',
+                        startTimestamp,
+                        ttlMs,
+                        deleteFn: async () => {
+                            const syncsConfigs = await db.knex
+                                .from<DBSyncConfig>('_nango_sync_configs')
+                                .select<DBSyncConfig[]>()
+                                .where({ nango_config_id: integration.id! })
+                                .limit(limit);
+
+                            for (const syncConfig of syncsConfigs) {
+                                await deleteSyncConfigData({ syncConfig, startTimestamp, ttlMs });
+                            }
+
+                            return syncsConfigs.length;
+                        }
+                    });
+
+                    // Delete connections
+                    await batchDelete({
+                        name: 'connections < integration',
+                        startTimestamp,
+                        ttlMs,
+                        deleteFn: async () => await connectionService.hardDeleteByIntegration({ limit, integrationId: integration.id! })
+                    });
+
+                    await configService.hardDelete(integration.id!);
+                }
+
+                return integrations.length;
+            }
+        });
+
+        // Delete sync configs and all associated data
+        await batchDelete({
+            name: 'sync configs',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const syncsConfigs = await getSoftDeletedSyncConfig({ limit, olderThan: deleteSyncConfigsOlderThan });
+
+                for (const syncConfig of syncsConfigs) {
+                    await deleteSyncConfigData({ syncConfig, startTimestamp, ttlMs });
+                }
+
+                return syncsConfigs.length;
             }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
+        });
+
+        // Delete connections and all associated data
+        await batchDelete({
+            name: 'connections',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const connections = await connectionService.getSoftDeleted({ limit, olderThan: deleteConnectionsOlderThan });
+
+                for (const connection of connections) {
+                    logger.info('Deleting connection...', connection.id, connection.connection_id);
+                    const resSyncs = await db.knex
+                        .select<
+                            {
+                                sync: Sync;
+                                syncConfig: DBSyncConfig;
+                            }[]
+                        >(db.knex.raw('row_to_json(_nango_syncs.*) as sync'), db.knex.raw('row_to_json(_nango_sync_configs.*) as "syncConfig"'))
+                        .from<Sync>('_nango_syncs')
+                        .join('_nango_sync_configs', '_nango_sync_configs.id', '_nango_syncs.sync_config_id')
+                        .where({ nango_connection_id: connection.id });
+                    for (const res of resSyncs) {
+                        await deleteSyncData({ ...res });
+                    }
+
+                    await connectionService.hardDelete(connection.id);
+                }
+
+                return connections.length;
             }
-            await setTimeout(1000);
-        }
+        });
     } finally {
         if (lock) {
             locking.release(lock);
         }
     }
 }
+
+async function batchDelete({
+    name,
+    deleteFn,
+    startTimestamp,
+    ttlMs
+}: {
+    name: string;
+    deleteFn: () => Promise<number>;
path: packages/server/lib/crons/deleteOldData.ts
user: login: TBonnin
body: we have to be careful when passing deleteFn: if an entity is used to retrieve children, we must ensure that the entity is deleted last. Otherwise if the deletion is interrupted by a exception or expires we wouldn't be able to retrieve the childer again and end up with orphans.
created_at: 2025-03-17T17:54:14Z
author_association: COLLABORATOR
diff_hunk: @@ -50,62 +70,226 @@ export async function exec(): Promise<void> {
 
     logger.info(`Starting`);
 
-    const ttlMs = (cronMinutes - 1) * 60 * 1000;
+    const ttlMs = cronMinutes * 60 * 1000 - 1000;
     const startTimestamp = Date.now();
     const lockKey = `lock:deleteOldData:cron`;
     let lock: Lock | undefined;
     try {
         try {
             lock = await locking.acquire(lockKey, ttlMs);
-        } catch {
-            logger.info(`Could not acquire lock, skipping`);
+        } catch (err) {
+            logger.info(`Could not acquire lock, skipping`, err);
             return;
         }
 
         // Delete jobs
-        while (true) {
-            const deleted = await deleteJobsByDate({ olderThan: deleteJobsOlderThan, limit });
-            logger.info(`Deleted ${deleted} jobs`);
-            if (deleted < limit) {
-                break;
-            }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
-            }
-            await setTimeout(1000);
-        }
+        await batchDelete({
+            name: 'jobs',
+            startTimestamp,
+            ttlMs,
path: packages/server/lib/crons/deleteOldData.ts
user: login: bodinsamuel
body: good point
created_at: 2025-03-18T13:16:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,62 +70,226 @@ export async function exec(): Promise<void> {
 
     logger.info(`Starting`);
 
-    const ttlMs = (cronMinutes - 1) * 60 * 1000;
+    const ttlMs = cronMinutes * 60 * 1000 - 1000;
     const startTimestamp = Date.now();
     const lockKey = `lock:deleteOldData:cron`;
     let lock: Lock | undefined;
     try {
         try {
             lock = await locking.acquire(lockKey, ttlMs);
-        } catch {
-            logger.info(`Could not acquire lock, skipping`);
+        } catch (err) {
+            logger.info(`Could not acquire lock, skipping`, err);
             return;
         }
 
         // Delete jobs
-        while (true) {
-            const deleted = await deleteJobsByDate({ olderThan: deleteJobsOlderThan, limit });
-            logger.info(`Deleted ${deleted} jobs`);
-            if (deleted < limit) {
-                break;
-            }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
-            }
-            await setTimeout(1000);
-        }
+        await batchDelete({
+            name: 'jobs',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteJobsByDate({ olderThan: deleteJobsOlderThan, limit })
+        });
 
         // Delete connect session
-        while (true) {
-            const deleted = await deleteExpiredConnectSession(db.knex, { olderThan: deleteConnectionSessionOlderThan, limit });
-            logger.info(`Deleted ${deleted} connect session`);
-            if (deleted < limit) {
-                break;
-            }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
-            }
-            await setTimeout(1000);
-        }
+        await batchDelete({
+            name: 'connect session',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredConnectSession(db.knex, { olderThan: deleteConnectionSessionOlderThan, limit })
+        });
 
         // Delete private keys
-        while (true) {
-            const deleted = await deleteExpiredPrivateKeys(db.knex, { olderThan: deletePrivateKeysOlderThan, limit });
-            logger.info(`Deleted ${deleted} private keys`);
-            if (deleted < limit) {
-                break;
+        await batchDelete({
+            name: 'private keys',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredPrivateKeys(db.knex, { olderThan: deletePrivateKeysOlderThan, limit })
+        });
+
+        // Delete oauth sessions
+        await batchDelete({
+            name: 'oauth sessions',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await oauthSessionService.deleteExpiredSessions({ limit, olderThan: deleteOauthSessionOlderThan })
+        });
+
+        // Delete invitations
+        await batchDelete({
+            name: 'invitations',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredInvitations({ limit, olderThan: deleteInvitationsOlderThan })
+        });
+
+        // Delete integrations and all associated data
+        await batchDelete({
+            name: 'integration',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const integrations = await configService.getSoftDeleted({ limit, olderThan: deleteConfigsOlderThan });
+                for (const integration of integrations) {
+                    logger.info('Deleting integration...', integration.id, integration.unique_key);
+
+                    await batchDelete({
+                        name: 'sync configs < integration',
+                        startTimestamp,
+                        ttlMs,
+                        deleteFn: async () => {
+                            const syncsConfigs = await db.knex
+                                .from<DBSyncConfig>('_nango_sync_configs')
+                                .select<DBSyncConfig[]>()
+                                .where({ nango_config_id: integration.id! })
+                                .limit(limit);
+
+                            for (const syncConfig of syncsConfigs) {
+                                await deleteSyncConfigData({ syncConfig, startTimestamp, ttlMs });
+                            }
+
+                            return syncsConfigs.length;
+                        }
+                    });
+
+                    // Delete connections
+                    await batchDelete({
+                        name: 'connections < integration',
+                        startTimestamp,
+                        ttlMs,
+                        deleteFn: async () => await connectionService.hardDeleteByIntegration({ limit, integrationId: integration.id! })
+                    });
+
+                    await configService.hardDelete(integration.id!);
+                }
+
+                return integrations.length;
+            }
+        });
+
+        // Delete sync configs and all associated data
+        await batchDelete({
+            name: 'sync configs',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const syncsConfigs = await getSoftDeletedSyncConfig({ limit, olderThan: deleteSyncConfigsOlderThan });
+
+                for (const syncConfig of syncsConfigs) {
+                    await deleteSyncConfigData({ syncConfig, startTimestamp, ttlMs });
+                }
+
+                return syncsConfigs.length;
             }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
+        });
+
+        // Delete connections and all associated data
+        await batchDelete({
+            name: 'connections',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const connections = await connectionService.getSoftDeleted({ limit, olderThan: deleteConnectionsOlderThan });
+
+                for (const connection of connections) {
+                    logger.info('Deleting connection...', connection.id, connection.connection_id);
+                    const resSyncs = await db.knex
+                        .select<
+                            {
+                                sync: Sync;
+                                syncConfig: DBSyncConfig;
+                            }[]
+                        >(db.knex.raw('row_to_json(_nango_syncs.*) as sync'), db.knex.raw('row_to_json(_nango_sync_configs.*) as "syncConfig"'))
+                        .from<Sync>('_nango_syncs')
+                        .join('_nango_sync_configs', '_nango_sync_configs.id', '_nango_syncs.sync_config_id')
+                        .where({ nango_connection_id: connection.id });
+                    for (const res of resSyncs) {
+                        await deleteSyncData({ ...res });
+                    }
+
+                    await connectionService.hardDelete(connection.id);
+                }
+
+                return connections.length;
             }
-            await setTimeout(1000);
-        }
+        });
     } finally {
         if (lock) {
             locking.release(lock);
         }
     }
 }
+
+async function batchDelete({
+    name,
+    deleteFn,
+    startTimestamp,
+    ttlMs
+}: {
+    name: string;
+    deleteFn: () => Promise<number>;
+    startTimestamp: number;
+    ttlMs: number;
+}) {
+    while (true) {
+        const deleted = await deleteFn();
+        if (deleted) {
+            logger.info(`Deleted ${deleted} ${name}`);
+        }
+        if (deleted < limit) {
+            break;
+        }
+        if (Date.now() - startTimestamp > ttlMs) {
+            logger.info(`Time limit reached, stopping`);
+            return;
+        }
+        await setTimeout(1000);
+    }
+}
path: packages/server/lib/crons/deleteOldData.ts
user: login: bodinsamuel
body: the problem with transaction is that it needs to keep everything in memory which is not always possible, degrading the whole instance performance.
If one fails, it will throw, bubble up and stop the cron, starting back from where it was next time
created_at: 2025-03-18T13:19:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,62 +70,226 @@ export async function exec(): Promise<void> {
 
     logger.info(`Starting`);
 
-    const ttlMs = (cronMinutes - 1) * 60 * 1000;
+    const ttlMs = cronMinutes * 60 * 1000 - 1000;
     const startTimestamp = Date.now();
     const lockKey = `lock:deleteOldData:cron`;
     let lock: Lock | undefined;
     try {
         try {
             lock = await locking.acquire(lockKey, ttlMs);
-        } catch {
-            logger.info(`Could not acquire lock, skipping`);
+        } catch (err) {
+            logger.info(`Could not acquire lock, skipping`, err);
             return;
         }
 
         // Delete jobs
-        while (true) {
-            const deleted = await deleteJobsByDate({ olderThan: deleteJobsOlderThan, limit });
-            logger.info(`Deleted ${deleted} jobs`);
-            if (deleted < limit) {
-                break;
-            }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
-            }
-            await setTimeout(1000);
-        }
+        await batchDelete({
+            name: 'jobs',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteJobsByDate({ olderThan: deleteJobsOlderThan, limit })
+        });
 
         // Delete connect session
-        while (true) {
-            const deleted = await deleteExpiredConnectSession(db.knex, { olderThan: deleteConnectionSessionOlderThan, limit });
-            logger.info(`Deleted ${deleted} connect session`);
-            if (deleted < limit) {
-                break;
-            }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
-            }
-            await setTimeout(1000);
-        }
+        await batchDelete({
+            name: 'connect session',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredConnectSession(db.knex, { olderThan: deleteConnectionSessionOlderThan, limit })
+        });
 
         // Delete private keys
-        while (true) {
-            const deleted = await deleteExpiredPrivateKeys(db.knex, { olderThan: deletePrivateKeysOlderThan, limit });
-            logger.info(`Deleted ${deleted} private keys`);
-            if (deleted < limit) {
-                break;
+        await batchDelete({
+            name: 'private keys',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredPrivateKeys(db.knex, { olderThan: deletePrivateKeysOlderThan, limit })
+        });
+
+        // Delete oauth sessions
+        await batchDelete({
+            name: 'oauth sessions',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await oauthSessionService.deleteExpiredSessions({ limit, olderThan: deleteOauthSessionOlderThan })
+        });
+
+        // Delete invitations
+        await batchDelete({
+            name: 'invitations',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredInvitations({ limit, olderThan: deleteInvitationsOlderThan })
+        });
+
+        // Delete integrations and all associated data
+        await batchDelete({
+            name: 'integration',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const integrations = await configService.getSoftDeleted({ limit, olderThan: deleteConfigsOlderThan });
+                for (const integration of integrations) {
+                    logger.info('Deleting integration...', integration.id, integration.unique_key);
+
+                    await batchDelete({
+                        name: 'sync configs < integration',
+                        startTimestamp,
+                        ttlMs,
+                        deleteFn: async () => {
+                            const syncsConfigs = await db.knex
+                                .from<DBSyncConfig>('_nango_sync_configs')
+                                .select<DBSyncConfig[]>()
+                                .where({ nango_config_id: integration.id! })
+                                .limit(limit);
+
+                            for (const syncConfig of syncsConfigs) {
+                                await deleteSyncConfigData({ syncConfig, startTimestamp, ttlMs });
+                            }
+
+                            return syncsConfigs.length;
+                        }
+                    });
+
+                    // Delete connections
+                    await batchDelete({
+                        name: 'connections < integration',
+                        startTimestamp,
+                        ttlMs,
+                        deleteFn: async () => await connectionService.hardDeleteByIntegration({ limit, integrationId: integration.id! })
+                    });
+
+                    await configService.hardDelete(integration.id!);
+                }
+
+                return integrations.length;
+            }
+        });
+
+        // Delete sync configs and all associated data
+        await batchDelete({
+            name: 'sync configs',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const syncsConfigs = await getSoftDeletedSyncConfig({ limit, olderThan: deleteSyncConfigsOlderThan });
+
+                for (const syncConfig of syncsConfigs) {
+                    await deleteSyncConfigData({ syncConfig, startTimestamp, ttlMs });
+                }
+
+                return syncsConfigs.length;
             }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
+        });
+
+        // Delete connections and all associated data
+        await batchDelete({
+            name: 'connections',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const connections = await connectionService.getSoftDeleted({ limit, olderThan: deleteConnectionsOlderThan });
+
+                for (const connection of connections) {
+                    logger.info('Deleting connection...', connection.id, connection.connection_id);
+                    const resSyncs = await db.knex
+                        .select<
+                            {
+                                sync: Sync;
+                                syncConfig: DBSyncConfig;
+                            }[]
+                        >(db.knex.raw('row_to_json(_nango_syncs.*) as sync'), db.knex.raw('row_to_json(_nango_sync_configs.*) as "syncConfig"'))
+                        .from<Sync>('_nango_syncs')
+                        .join('_nango_sync_configs', '_nango_sync_configs.id', '_nango_syncs.sync_config_id')
+                        .where({ nango_connection_id: connection.id });
+                    for (const res of resSyncs) {
+                        await deleteSyncData({ ...res });
+                    }
+
+                    await connectionService.hardDelete(connection.id);
+                }
+
+                return connections.length;
             }
-            await setTimeout(1000);
-        }
+        });
     } finally {
         if (lock) {
             locking.release(lock);
         }
     }
 }
+
+async function batchDelete({
+    name,
+    deleteFn,
+    startTimestamp,
+    ttlMs
+}: {
+    name: string;
+    deleteFn: () => Promise<number>;
path: packages/server/lib/crons/deleteOldData.ts
user: login: bodinsamuel
body: this is what happened yes, we always delete at the end (unless mistaken)
created_at: 2025-03-18T13:22:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -50,62 +70,226 @@ export async function exec(): Promise<void> {
 
     logger.info(`Starting`);
 
-    const ttlMs = (cronMinutes - 1) * 60 * 1000;
+    const ttlMs = cronMinutes * 60 * 1000 - 1000;
     const startTimestamp = Date.now();
     const lockKey = `lock:deleteOldData:cron`;
     let lock: Lock | undefined;
     try {
         try {
             lock = await locking.acquire(lockKey, ttlMs);
-        } catch {
-            logger.info(`Could not acquire lock, skipping`);
+        } catch (err) {
+            logger.info(`Could not acquire lock, skipping`, err);
             return;
         }
 
         // Delete jobs
-        while (true) {
-            const deleted = await deleteJobsByDate({ olderThan: deleteJobsOlderThan, limit });
-            logger.info(`Deleted ${deleted} jobs`);
-            if (deleted < limit) {
-                break;
-            }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
-            }
-            await setTimeout(1000);
-        }
+        await batchDelete({
+            name: 'jobs',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteJobsByDate({ olderThan: deleteJobsOlderThan, limit })
+        });
 
         // Delete connect session
-        while (true) {
-            const deleted = await deleteExpiredConnectSession(db.knex, { olderThan: deleteConnectionSessionOlderThan, limit });
-            logger.info(`Deleted ${deleted} connect session`);
-            if (deleted < limit) {
-                break;
-            }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
-            }
-            await setTimeout(1000);
-        }
+        await batchDelete({
+            name: 'connect session',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredConnectSession(db.knex, { olderThan: deleteConnectionSessionOlderThan, limit })
+        });
 
         // Delete private keys
-        while (true) {
-            const deleted = await deleteExpiredPrivateKeys(db.knex, { olderThan: deletePrivateKeysOlderThan, limit });
-            logger.info(`Deleted ${deleted} private keys`);
-            if (deleted < limit) {
-                break;
+        await batchDelete({
+            name: 'private keys',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredPrivateKeys(db.knex, { olderThan: deletePrivateKeysOlderThan, limit })
+        });
+
+        // Delete oauth sessions
+        await batchDelete({
+            name: 'oauth sessions',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await oauthSessionService.deleteExpiredSessions({ limit, olderThan: deleteOauthSessionOlderThan })
+        });
+
+        // Delete invitations
+        await batchDelete({
+            name: 'invitations',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => await deleteExpiredInvitations({ limit, olderThan: deleteInvitationsOlderThan })
+        });
+
+        // Delete integrations and all associated data
+        await batchDelete({
+            name: 'integration',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const integrations = await configService.getSoftDeleted({ limit, olderThan: deleteConfigsOlderThan });
+                for (const integration of integrations) {
+                    logger.info('Deleting integration...', integration.id, integration.unique_key);
+
+                    await batchDelete({
+                        name: 'sync configs < integration',
+                        startTimestamp,
+                        ttlMs,
+                        deleteFn: async () => {
+                            const syncsConfigs = await db.knex
+                                .from<DBSyncConfig>('_nango_sync_configs')
+                                .select<DBSyncConfig[]>()
+                                .where({ nango_config_id: integration.id! })
+                                .limit(limit);
+
+                            for (const syncConfig of syncsConfigs) {
+                                await deleteSyncConfigData({ syncConfig, startTimestamp, ttlMs });
+                            }
+
+                            return syncsConfigs.length;
+                        }
+                    });
+
+                    // Delete connections
+                    await batchDelete({
+                        name: 'connections < integration',
+                        startTimestamp,
+                        ttlMs,
+                        deleteFn: async () => await connectionService.hardDeleteByIntegration({ limit, integrationId: integration.id! })
+                    });
+
+                    await configService.hardDelete(integration.id!);
+                }
+
+                return integrations.length;
+            }
+        });
+
+        // Delete sync configs and all associated data
+        await batchDelete({
+            name: 'sync configs',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const syncsConfigs = await getSoftDeletedSyncConfig({ limit, olderThan: deleteSyncConfigsOlderThan });
+
+                for (const syncConfig of syncsConfigs) {
+                    await deleteSyncConfigData({ syncConfig, startTimestamp, ttlMs });
+                }
+
+                return syncsConfigs.length;
             }
-            if (Date.now() - startTimestamp > ttlMs) {
-                logger.info(`Time limit reached, stopping`);
-                return;
+        });
+
+        // Delete connections and all associated data
+        await batchDelete({
+            name: 'connections',
+            startTimestamp,
+            ttlMs,
+            deleteFn: async () => {
+                const connections = await connectionService.getSoftDeleted({ limit, olderThan: deleteConnectionsOlderThan });
+
+                for (const connection of connections) {
+                    logger.info('Deleting connection...', connection.id, connection.connection_id);
+                    const resSyncs = await db.knex
+                        .select<
+                            {
+                                sync: Sync;
+                                syncConfig: DBSyncConfig;
+                            }[]
+                        >(db.knex.raw('row_to_json(_nango_syncs.*) as sync'), db.knex.raw('row_to_json(_nango_sync_configs.*) as "syncConfig"'))
+                        .from<Sync>('_nango_syncs')
+                        .join('_nango_sync_configs', '_nango_sync_configs.id', '_nango_syncs.sync_config_id')
+                        .where({ nango_connection_id: connection.id });
+                    for (const res of resSyncs) {
+                        await deleteSyncData({ ...res });
+                    }
+
+                    await connectionService.hardDelete(connection.id);
+                }
+
+                return connections.length;
             }
-            await setTimeout(1000);
-        }
+        });
     } finally {
         if (lock) {
             locking.release(lock);
         }
     }
 }
+
+async function batchDelete({
+    name,
+    deleteFn,
+    startTimestamp,
+    ttlMs
+}: {
+    name: string;
+    deleteFn: () => Promise<number>;
path: packages/server/lib/crons/deleteOldData.ts
user: login: TBonnin
body: this is what it is done indeed. just commenting that it could be easy to forget about this implicit assumption in the next batchDeletes we are going to add eventually. I don't think we can enforce anything unfortunately
created_at: 2025-03-18T13:34:36Z
author_association: COLLABORATOR

PR comments:
user: login: linear[bot]
created_at: 2025-03-14T19:51:51Z
body: <p><a href="https://linear.app/nango/issue/NAN-2912/delete-old-data">NAN-2912 Delete old data</a></p>
author_association: NONE
user: login: bodinsamuel
created_at: 2025-03-24T10:14:06Z
body: Tested on staging, it's working as intended 🤞🏻 
author_association: CONTRIBUTOR