Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 () {};
139 changes: 139 additions & 0 deletions packages/server/lib/crons/manageGrowthAddons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import * as cron from 'node-cron';

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

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

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

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

type GrowthAddonSchedulingColumn = keyof Pick<DBPlan, 'growth_features_starts_at' | 'growth_features_ends_at'>;
type GrowthAddonOperation = 'enable' | 'disable';

const growthAddonOperations = {
enable: { hasGrowthFeatures: true, schedulingColumn: 'growth_features_starts_at' },
disable: { hasGrowthFeatures: false, schedulingColumn: 'growth_features_ends_at' }
} as const satisfies Record<GrowthAddonOperation, { hasGrowthFeatures: boolean; schedulingColumn: GrowthAddonSchedulingColumn }>;

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(date = 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(date);
await disableGrowthAddon(date);
} finally {
try {
await locking.release(lock);
} catch (err) {
logger.error('Error releasing growth add-on cron lock', { lock: lock.key, err });
}
}
}

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(date: Date) {
const accountIds = await updateGrowthAddonState(date, 'enable');
if (accountIds.length > 0) {
logger.info('Enabled growth add-on for accounts.', { accountIds: accountIds });
}
}

async function disableGrowthAddon(date: Date) {
const accountIds = await updateGrowthAddonState(date, 'disable');
if (accountIds.length > 0) {
logger.info('Disabled growth add-on for accounts.', { accountIds: accountIds });
}
}

async function updateGrowthAddonState(date: Date, operation: GrowthAddonOperation): Promise<number[]> {
const { hasGrowthFeatures, schedulingColumn } = growthAddonOperations[operation];
const accountIds = await Promise.all(
getPlansToFilterBy(operation).map(async (plan) => {
const addonFlags = getGrowthAddonFlags(plan, hasGrowthFeatures);

const updated = await db.knex
.from<DBPlan>('plans')
.where('name', plan.code)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
.whereNotNull(schedulingColumn)
.where(schedulingColumn, '<=', date)
.update({
has_growth_features: hasGrowthFeatures,
[schedulingColumn]: null,
...addonFlags,
updated_at: db.knex.fn.now()
})
.returning('account_id');

return updated.map((plan) => plan.account_id);
})
);
return accountIds.flat();
}

function getPlansToFilterBy(operation: GrowthAddonOperation): PlanDefinition[] {
if (operation === 'disable') {
return plansList;
}

return PLANS_WITH_GROWTH_ADD_ON.map((planCode) => {
const definition = getPlanDefinition(planCode);
if (!definition) {
throw new Error(`Missing plan definition for ${planCode}`);
}
return definition;
});
}
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
25 changes: 12 additions & 13 deletions packages/shared/lib/services/plans/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,14 @@ function isPlanUnchanged(currentPlan: DBPlan, newPlan: PlanDefinition): boolean
return currentPlan.name === newPlan.code;
}

export function getGrowthAddonFlags(definition: PlanDefinition, hasGrowthFeatures: boolean): Partial<PlanDefinition['flags']> {
const flags: Partial<PlanDefinition['flags']> = {};
for (const flag of Object.keys(GROWTH_FEATURE_FLAGS) as (keyof typeof GROWTH_FEATURE_FLAGS)[]) {
flags[flag] = hasGrowthFeatures ? GROWTH_FEATURE_FLAGS[flag] : (definition.flags[flag] as boolean);
}
return flags;
}

export async function setGrowthAddon(
db: Knex,
team: DBTeam,
Expand All @@ -168,16 +176,12 @@ export async function setGrowthAddon(
return Err('Received a plan not linked to the plansList');
}

const flags: Partial<PlanDefinition['flags']> = {};
for (const flag of Object.keys(GROWTH_FEATURE_FLAGS) as (keyof typeof GROWTH_FEATURE_FLAGS)[]) {
flags[flag] = hasGrowthFeatures ? GROWTH_FEATURE_FLAGS[flag] : (definition.flags[flag] as boolean);
}

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
...getGrowthAddonFlags(definition, hasGrowthFeatures)
});
if (updated.isErr()) {
return Err(new Error('Failed to update growth add-on', { cause: updated.error }));
Expand Down Expand Up @@ -263,13 +267,7 @@ export function mergeFlags({ currentPlan, newPlanDefinition }: { currentPlan: DB

if (canHaveGrowthAddon(newPlanDefinition.code)) {
// Force-update growth feature flags on top of merged plan flags, based on whether the add-on is enabled or not.
const growth: Partial<PlanDefinition['flags']> = {};
const growthFeatureFlags = Object.keys(GROWTH_FEATURE_FLAGS) as (keyof typeof GROWTH_FEATURE_FLAGS)[];
for (const featureFlag of growthFeatureFlags) {
growth[featureFlag] = hasGrowthFeatures ? GROWTH_FEATURE_FLAGS[featureFlag] : (newPlanDefinition.flags[featureFlag] as boolean);
}

flags = { ...flags, ...growth };
flags = { ...flags, ...getGrowthAddonFlags(newPlanDefinition, hasGrowthFeatures) };
}

return flags;
Expand Down Expand Up @@ -314,6 +312,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
22 changes: 21 additions & 1 deletion packages/shared/lib/services/plans/plans.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { describe, expect, it } from 'vitest';

import { getPlanDefinition, plansList } from './definitions.js';
import { mergeFlags } from './plans.js';
import { getGrowthAddonFlags, mergeFlags } from './plans.js';

import type { DBPlan, PlanDefinition } from '@nangohq/types';

describe('mergeFlags', () => {
it('restores growth feature flags to the plan defaults when the add-on is disabled', () => {
const definition = getPlanDefinition('pay-as-you-go')!;

expect(getGrowthAddonFlags(definition, true)).toMatchObject({
has_otel: true,
has_rbac: true,
can_override_docs_connect_url: true,
can_customize_connect_ui_theme: true,
can_disable_connect_ui_watermark: true
});
expect(getGrowthAddonFlags(definition, false)).toMatchObject({
has_otel: false,
has_rbac: false,
can_override_docs_connect_url: false,
can_customize_connect_ui_theme: false,
can_disable_connect_ui_watermark: false
});
});

it('should cap only connections and function runtime on the free plan', () => {
expect(getPlanDefinition('free')?.flags).toMatchObject({
connections_max: 10,
Expand Down Expand Up @@ -275,6 +294,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