From 88ad524aaa0f258fd3af8de37b3b6e44f3a3720a Mon Sep 17 00:00:00 2001 From: fenos Date: Mon, 17 Feb 2025 15:42:03 +0100 Subject: [PATCH] fix: add feature flags to tenants to selectively apply migrations --- .../0015-migrations-feature-flags.sql | 1 + src/config.ts | 2 + src/http/routes/admin/tenants.ts | 8 +- .../database/migrations/progressive.ts | 97 ++++++++++++------- src/internal/database/tenant.ts | 3 + 5 files changed, 74 insertions(+), 37 deletions(-) create mode 100644 migrations/multitenant/0015-migrations-feature-flags.sql diff --git a/migrations/multitenant/0015-migrations-feature-flags.sql b/migrations/multitenant/0015-migrations-feature-flags.sql new file mode 100644 index 00000000..0c18457a --- /dev/null +++ b/migrations/multitenant/0015-migrations-feature-flags.sql @@ -0,0 +1 @@ +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS migrations_feature_flags text[] NULL; \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index c477cbab..cd9aa96d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -40,6 +40,7 @@ type StorageConfigType = { dbSuperUser: string dbSearchPath: string dbMigrationStrategy: MultitenantMigrationStrategy + dbMigrationFeatureFlagsEnabled: boolean dbPostgresVersion?: string databaseURL: string databaseSSLRootCert?: string @@ -299,6 +300,7 @@ export function getConfig(options?: { reload?: boolean }): StorageConfigType { ), dbSuperUser: getOptionalConfigFromEnv('DB_SUPER_USER') || 'postgres', dbMigrationStrategy: getOptionalConfigFromEnv('DB_MIGRATIONS_STRATEGY') || 'on_request', + dbMigrationFeatureFlagsEnabled: getOptionalConfigFromEnv('DB_FEATURE_FLAGS_ENABLED') === 'true', // Database - Connection dbSearchPath: getOptionalConfigFromEnv('DATABASE_SEARCH_PATH', 'DB_SEARCH_PATH') || '', diff --git a/src/http/routes/admin/tenants.ts b/src/http/routes/admin/tenants.ts index 132715b2..edc0bf80 100644 --- a/src/http/routes/admin/tenants.ts +++ b/src/http/routes/admin/tenants.ts @@ -31,6 +31,7 @@ const patchSchema = { serviceKey: { type: 'string' }, tracingMode: { type: 'string' }, disableEvents: { type: 'array', items: { type: 'string' }, nullable: true }, + migrationsFeatureFlags: { type: 'array', items: { type: 'string' }, nullable: true }, features: { type: 'object', properties: { @@ -90,6 +91,7 @@ interface tenantDBInterface { feature_s3_protocol?: boolean feature_image_transformation?: boolean image_transformation_max_resolution?: number + migrations_feature_flags?: string[] } export default async function routes(fastify: FastifyInstance) { @@ -163,6 +165,7 @@ export default async function routes(fastify: FastifyInstance) { migrations_status, tracing_mode, disable_events, + migrations_feature_flags, } = tenant return { @@ -192,6 +195,7 @@ export default async function routes(fastify: FastifyInstance) { migrationStatus: migrations_status, tracingMode: tracing_mode, disableEvents: disable_events, + migrationsFeatureFlags: migrations_feature_flags, } } }) @@ -259,6 +263,7 @@ export default async function routes(fastify: FastifyInstance) { maxConnections, tracingMode, disableEvents, + migrationsFeatureFlags, } = request.body const { tenantId } = request.params @@ -284,6 +289,7 @@ export default async function routes(fastify: FastifyInstance) { : features?.imageTransformation?.maxResolution, tracing_mode: tracingMode, disable_events: disableEvents, + migrations_feature_flags: migrationsFeatureFlags, }) .where('id', tenantId) @@ -300,7 +306,7 @@ export default async function routes(fastify: FastifyInstance) { if (e instanceof Error) { request.executionError = e } - progressiveMigrations.addTenant(tenantId) + progressiveMigrations.addTenant(tenantId, true) } } diff --git a/src/internal/database/migrations/progressive.ts b/src/internal/database/migrations/progressive.ts index 89b06f34..bc4e8af4 100644 --- a/src/internal/database/migrations/progressive.ts +++ b/src/internal/database/migrations/progressive.ts @@ -2,6 +2,10 @@ import { logger, logSchema } from '../../monitoring' import { getTenantConfig, TenantMigrationStatus } from '../tenant' import { RunMigrationsOnTenants } from '@storage/events' import { areMigrationsUpToDate } from '@internal/database/migrations/migrate' +import { getConfig } from '../../../config' +import { DBMigration } from '@internal/database/migrations/types' + +const { dbMigrationFeatureFlagsEnabled } = getConfig() export class ProgressiveMigrations { protected tenants: string[] = [] @@ -48,13 +52,31 @@ export class ProgressiveMigrations { }) } - addTenant(tenant: string) { + async addTenant(tenant: string, forceMigrate?: boolean) { const tenantIndex = this.tenants.indexOf(tenant) if (tenantIndex !== -1) { return } + // check feature flags + if (dbMigrationFeatureFlagsEnabled && !forceMigrate) { + const { migrationFeatureFlags, migrationVersion } = await getTenantConfig(tenant) + if (!migrationFeatureFlags || !migrationVersion) { + return + } + + // we only want to run migrations for tenants that have the feature flag enabled + // a feature flag can be any migration version that is greater than the current migration version + const migrationFeatureFlagsEnabled = migrationFeatureFlags.some( + (flag) => DBMigration[flag as keyof typeof DBMigration] > DBMigration[migrationVersion] + ) + + if (!migrationFeatureFlagsEnabled) { + return + } + } + this.tenants.push(tenant) if (this.tenants.length < this.options.maxSize || this.emittingJobs) { @@ -95,43 +117,46 @@ export class ProgressiveMigrations { protected async createJobs(maxJobs: number) { this.emittingJobs = true - const tenantsBatch = this.tenants.splice(0, maxJobs) - const jobs = await Promise.allSettled( - tenantsBatch.map(async (tenant) => { - const tenantConfig = await getTenantConfig(tenant) - const migrationsUpToDate = await areMigrationsUpToDate(tenant) - - if (migrationsUpToDate || tenantConfig.syncMigrationsDone) { - return - } - - const scheduleAt = new Date() - scheduleAt.setMinutes(scheduleAt.getMinutes() + 5) - const scheduleForLater = - tenantConfig.migrationStatus === TenantMigrationStatus.FAILED_STALE - ? scheduleAt - : undefined - - return new RunMigrationsOnTenants({ - tenantId: tenant, - scheduleAt: scheduleForLater, - tenant: { - host: '', - ref: tenant, - }, + try { + const tenantsBatch = this.tenants.splice(0, maxJobs) + const jobs = await Promise.allSettled( + tenantsBatch.map(async (tenant) => { + const tenantConfig = await getTenantConfig(tenant) + const migrationsUpToDate = await areMigrationsUpToDate(tenant) + + if (migrationsUpToDate || tenantConfig.syncMigrationsDone) { + return + } + + const scheduleAt = new Date() + scheduleAt.setMinutes(scheduleAt.getMinutes() + 5) + const scheduleForLater = + tenantConfig.migrationStatus === TenantMigrationStatus.FAILED_STALE + ? scheduleAt + : undefined + + return new RunMigrationsOnTenants({ + tenantId: tenant, + scheduleAt: scheduleForLater, + tenant: { + host: '', + ref: tenant, + }, + }) }) - }) - ) + ) - const validJobs = jobs - .map((job) => { - if (job.status === 'fulfilled' && job.value) { - return job.value - } - }) - .filter((job) => job) + const validJobs = jobs + .map((job) => { + if (job.status === 'fulfilled' && job.value) { + return job.value + } + }) + .filter((job) => job) - await RunMigrationsOnTenants.batchSend(validJobs as RunMigrationsOnTenants[]) - this.emittingJobs = false + await RunMigrationsOnTenants.batchSend(validJobs as RunMigrationsOnTenants[]) + } finally { + this.emittingJobs = false + } } } diff --git a/src/internal/database/tenant.ts b/src/internal/database/tenant.ts index 3797fac6..0860284d 100644 --- a/src/internal/database/tenant.ts +++ b/src/internal/database/tenant.ts @@ -31,6 +31,7 @@ interface TenantConfig { } migrationVersion?: keyof typeof DBMigration migrationStatus?: TenantMigrationStatus + migrationFeatureFlags?: string[] syncMigrationsDone?: boolean tracingMode?: string disableEvents?: string[] @@ -134,6 +135,7 @@ export async function getTenantConfig(tenantId: string): Promise { migrations_status, tracing_mode, disable_events, + migrations_feature_flags, } = tenant const serviceKey = decrypt(service_key) @@ -163,6 +165,7 @@ export async function getTenantConfig(tenantId: string): Promise { migrationVersion: migrations_version, migrationStatus: migrations_status, migrationsRun: false, + migrationFeatureFlags: migrations_feature_flags, tracingMode: tracing_mode, disableEvents: disable_events, }