Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* @param {import('knex').Knex} knex
*/
exports.up = async function (knex) {
await knex.raw(`ALTER TABLE plans ADD COLUMN IF NOT EXISTS growth_features_starts_at timestamptz`);
Comment thread
ErickRDev marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The hourly growth-addon update filters on growth_features_starts_at <= snapshot, but this migration creates no index for that predicate. Add a partial index so each hourly run does not scan every plan row as the plans table grows.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/database/lib/migrations/20260911120000_plans_add_growth_addon_start.cjs, line 5:

<comment>The hourly growth-addon update filters on `growth_features_starts_at <= snapshot`, but this migration creates no index for that predicate. Add a partial index so each hourly run does not scan every plan row as the plans table grows.</comment>

<file context>
@@ -0,0 +1,11 @@
+ * @param {import('knex').Knex} knex
+ */
+exports.up = async function (knex) {
+    await knex.raw(`ALTER TABLE plans ADD COLUMN IF NOT EXISTS growth_features_starts_at timestamptz`);
+};
+
</file context>

};

/**
* @param {import('knex').Knex} knex
*/
exports.down = async function () {};
102 changes: 102 additions & 0 deletions packages/server/lib/crons/manageGrowthAddons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import * as cron from 'node-cron';

import db from '@nangohq/database';
import { getLocking } from '@nangohq/kvstore';
import { PLANS_WITH_GROWTH_ADD_ON } from '@nangohq/shared';
import { flagHasPlan, getLogger, metrics } from '@nangohq/utils';

import type { Lock } from '@nangohq/kvstore';
import type { DBPlan } from '@nangohq/types';

const logger = getLogger('cron.growthFeatures');

const cronMinutes = 60;
const cronExpression = `*/${cronMinutes} * * * *`;
const lockTtlMs = cronMinutes * 60 * 1000;

export function manageGrowthAddonsCron(): void {
if (!flagHasPlan) {
return;
}

cron.schedule(cronExpression, async () => {
const start = Date.now();
let success = true;
try {
await exec();
} catch (err) {
success = false;
logger.error('Failed to execute growth add-on management cron', { err });
} finally {
metrics.duration(metrics.Types.CRON_MANAGE_GROWTH_ADDON, Date.now() - start, { success: String(success) });
logger.info('✅ done');
}
});
}

export async function exec(now = new Date()): Promise<void> {
const locking = await getLocking();
let lock: Lock | undefined;
try {
lock = await locking.acquire('lock:growthFeatures:cron', lockTtlMs);
} catch (err) {
logger.info('Could not acquire lock, skipping', err);
return;
}

try {
await reportCorruptedPlans();
await enableGrowthAddon(now);
await disableGrowthAddon(now);
} finally {
await locking.release(lock);
Comment thread
ErickRDev marked this conversation as resolved.
Outdated
}
}

async function reportCorruptedPlans() {
const corrupted = await db.knex
.from<Pick<DBPlan, 'id' | 'account_id' | 'name'>>('plans')
.select('id', 'account_id', 'name')
.where('has_growth_features', true)
.whereNotIn('name', PLANS_WITH_GROWTH_ADD_ON);

if (corrupted.length > 0) {
for (const plan of corrupted) {
logger.error('Growth features enabled for a plan that does not support the add-on', {
planId: plan.id,
accountId: plan.account_id,
planName: plan.name
});
}
}

metrics.gauge(metrics.Types.GROWTH_ADDON_CORRUPTED_STATE_COUNT, corrupted.length);
}

async function enableGrowthAddon(snapshot: Date) {
const enabled = await db.knex
.from<DBPlan>('plans')
.whereIn('name', PLANS_WITH_GROWTH_ADD_ON)
.whereNotNull('growth_features_starts_at')
.where('growth_features_starts_at', '<=', snapshot)
.update({ has_growth_features: true, growth_features_starts_at: null, updated_at: db.knex.fn.now() })
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
.returning('*');

if (enabled.length > 0) {
logger.info('Enabled growth add-on for accounts.', { accountIds: enabled.map((p) => p.account_id) });
}
}

async function disableGrowthAddon(snapshot: Date) {
const disabled = await db.knex
.from<DBPlan>('plans')
.where('has_growth_features', true)
.whereNotNull('growth_features_ends_at')
.where('growth_features_ends_at', '<=', snapshot)
.update({ has_growth_features: false, growth_features_ends_at: null, updated_at: db.knex.fn.now() })
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
.returning('*');

if (disabled.length > 0) {
logger.info('Disabled growth add-on for accounts.', { accountIds: disabled.map((p) => p.account_id) });
}
}
1 change: 1 addition & 0 deletions packages/server/lib/formatters/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export function planToApi(plan: DBPlan): ApiPlan {
trial_end_notified_at: plan.trial_end_notified_at ? plan.trial_end_notified_at.toISOString() : null,
orb_future_plan_at: plan.orb_future_plan_at?.toISOString() || null,
orb_subscribed_at: plan.orb_subscribed_at?.toISOString() || null,
growth_features_starts_at: plan.growth_features_starts_at?.toISOString() || null,
growth_features_ends_at: plan.growth_features_ends_at?.toISOString() || null,
created_at: plan.created_at.toISOString(),
updated_at: plan.updated_at.toISOString()
Expand Down
2 changes: 2 additions & 0 deletions packages/server/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { destroyAuditDb, migrateAuditDb, startAuditPartitions } from './auditDb.
import publisher from './clients/publisher.client.js';
import { deleteOldData } from './crons/deleteOldData.js';
import { lambdaKeepWarmCron } from './crons/lambdaKeepWarm.js';
import { manageGrowthAddonsCron } from './crons/manageGrowthAddons.js';
import { refreshConnectionsCron } from './crons/refreshConnections.js';
import { timeoutFunctionAsyncJobsCron } from './crons/timeoutFunctionAsyncJobs.js';
import { timeoutLogsOperations } from './crons/timeoutLogsOperations.js';
Expand Down Expand Up @@ -101,6 +102,7 @@ refreshConnectionsCron();
timeoutLogsOperations();
timeoutFunctionAsyncJobsCron();
deleteOldData();
manageGrowthAddonsCron();
trialCron();
lambdaKeepWarmCron();
tasks.start();
Expand Down
1 change: 1 addition & 0 deletions packages/shared/lib/seeders/plan.seeder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export function getTestPlan(override?: Partial<DBPlan>): DBPlan {
account_id: 1,
name: 'free',
has_growth_features: false,
growth_features_starts_at: null,
growth_features_ends_at: null,
stripe_customer_id: null,
stripe_payment_id: null,
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/lib/services/plans/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export async function setGrowthAddon(
const updated = await updatePlanByTeam(db, {
account_id: team.id,
has_growth_features: hasGrowthFeatures,
growth_features_starts_at: null,
growth_features_ends_at: hasGrowthFeatures ? endsAt : null,
...flags
});
Expand Down Expand Up @@ -314,6 +315,7 @@ function mergePlanFlags({ currentPlan, newPlanDefinition }: { currentPlan: DBPla
case 'updated_at':
// Growth add-on related, skip them
case 'has_growth_features':
case 'growth_features_starts_at':
case 'growth_features_ends_at':
break;
// BOOLEAN FLAGS - keep override if false
Expand Down
1 change: 1 addition & 0 deletions packages/shared/lib/services/plans/plans.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ function makePlan({
account_id: 1,
name: code,
has_growth_features: hasGrowthFeatures,
growth_features_starts_at: null,
growth_features_ends_at: null,
created_at: new Date(),
updated_at: new Date(),
Expand Down
1 change: 1 addition & 0 deletions packages/types/lib/plans/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface DBPlan extends Timestamps {
orb_future_plan_at: Date | null;
orb_subscribed_at: Date | null;
has_growth_features: boolean;
growth_features_starts_at: Date | null;
growth_features_ends_at: Date | null;

// Trial
Expand Down
3 changes: 3 additions & 0 deletions packages/utils/lib/telemetry/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export enum Types {
PROXY_REDIRECT = 'nango.server.proxy.redirect',
PROXY_BASE_URL_OVERRIDE_DENIED = 'nango.server.proxy.baseUrlOverrideDenied',

CRON_MANAGE_GROWTH_ADDON = 'nango.server.cron.manageGrowthAddon',
GROWTH_ADDON_CORRUPTED_STATE_COUNT = 'nango.server.growthAddon.corrupted.count',

CRON_REFRESH_CONNECTIONS = 'nango.server.cron.refreshConnections',
CRON_REFRESH_CONNECTIONS_FAILED = 'nango.server.cron.refreshConnections.failed',
CRON_REFRESH_CONNECTIONS_SUCCESS = 'nango.server.cron.refreshConnections.success',
Expand Down
5 changes: 2 additions & 3 deletions packages/webapp/src/pages/Team/Billing/planVisibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,12 @@ export function isRetiredPlan(code: DBPlan['name']): boolean {
return PLAN_IS_RETIRED[code];
}

/** Only a scheduled migration sets `pending-activation`. `growthAddonState` never returns it. */
export type GrowthAddonState = 'none' | 'active' | 'pending-removal' | 'pending-activation';

/** A scheduled removal still reads as `has_growth_features` until its date, so the date separates the two. */
/** Scheduled transitions retain their current flag until their date, so the dates separate them from steady states. */
export function growthAddonState(plan: ApiPlan | null | undefined): GrowthAddonState {
if (!plan?.has_growth_features) {
return 'none';
return plan?.growth_features_starts_at ? 'pending-activation' : 'none';
}
return plan.growth_features_ends_at ? 'pending-removal' : 'active';
}
Loading