From 7462465a0ba9ab151b7b21b8bc97c00ecbcf8973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 1 Oct 2025 09:57:18 +0100 Subject: [PATCH] chore: resource limits service (#10709) https://linear.app/unleash/issue/2-3927/implement-resource-limits-service Implements a resource limits service. The implementation looks trivial (or even redundant) in OSS, but by implementing a resource limits service we can make this potentially dynamic and overridable. --- .../api-tokens/createApiTokenService.ts | 7 ++++++ .../createFeatureToggleService.ts | 14 +++++++---- .../feature-toggle/feature-toggle-service.ts | 24 +++++++++++-------- .../features/project/createProjectService.ts | 9 +++++++ src/lib/features/project/project-service.ts | 15 ++++++------ .../resource-limits-service.ts | 14 +++++++++++ .../features/segment/createSegmentService.ts | 8 ++++++- src/lib/features/segment/segment-service.ts | 14 +++++------ src/lib/services/api-token-service.ts | 16 ++++++------- src/lib/services/index.ts | 9 +++++++ src/lib/ui-config/ui-config-service.ts | 9 ++++++- 11 files changed, 100 insertions(+), 39 deletions(-) create mode 100644 src/lib/features/resource-limits/resource-limits-service.ts diff --git a/src/lib/features/api-tokens/createApiTokenService.ts b/src/lib/features/api-tokens/createApiTokenService.ts index c61d04172b..f9b909d116 100644 --- a/src/lib/features/api-tokens/createApiTokenService.ts +++ b/src/lib/features/api-tokens/createApiTokenService.ts @@ -9,6 +9,7 @@ import { } from '../events/createEventsService.js'; import FakeApiTokenStore from '../../../test/fixtures/fake-api-token-store.js'; import { ApiTokenStore } from '../../db/api-token-store.js'; +import { ResourceLimitsService } from '../resource-limits/resource-limits-service.js'; export const createApiTokenService = ( db: Db, @@ -23,11 +24,13 @@ export const createApiTokenService = ( ); const environmentStore = new EnvironmentStore(db, eventBus, config); const eventService = createEventsService(db, config); + const resourceLimitsService = new ResourceLimitsService(config); return new ApiTokenService( { apiTokenStore, environmentStore }, config, eventService, + resourceLimitsService, ); }; @@ -36,23 +39,27 @@ export const createFakeApiTokenService = ( ): { apiTokenService: ApiTokenService; eventService: EventService; + resourceLimitsService: ResourceLimitsService; apiTokenStore: FakeApiTokenStore; environmentStore: IEnvironmentStore; } => { const apiTokenStore = new FakeApiTokenStore(); const environmentStore = new FakeEnvironmentStore(); const eventService = createFakeEventsService(config); + const resourceLimitsService = new ResourceLimitsService(config); const apiTokenService = new ApiTokenService( { apiTokenStore, environmentStore }, config, eventService, + resourceLimitsService, ); return { apiTokenService, apiTokenStore, eventService, + resourceLimitsService, environmentStore, }; }; diff --git a/src/lib/features/feature-toggle/createFeatureToggleService.ts b/src/lib/features/feature-toggle/createFeatureToggleService.ts index 27c52c93c1..03a8ff06e3 100644 --- a/src/lib/features/feature-toggle/createFeatureToggleService.ts +++ b/src/lib/features/feature-toggle/createFeatureToggleService.ts @@ -65,12 +65,13 @@ import { createFakeFeatureLinkService, createFeatureLinkService, } from '../feature-links/createFeatureLinkService.js'; +import { ResourceLimitsService } from '../resource-limits/resource-limits-service.js'; export const createFeatureToggleService = ( db: Db, config: IUnleashConfig, ): FeatureToggleService => { - const { getLogger, eventBus, flagResolver, resourceLimits } = config; + const { getLogger, eventBus, flagResolver } = config; const featureStrategiesStore = new FeatureStrategiesStore( db, eventBus, @@ -138,6 +139,8 @@ export const createFeatureToggleService = ( const featureLinkService = createFeatureLinkService(config)(db); + const resourceLimitsService = new ResourceLimitsService(config); + const featureToggleService = new FeatureToggleService( { featureStrategiesStore, @@ -149,7 +152,7 @@ export const createFeatureToggleService = ( contextFieldStore, strategyStore, }, - { getLogger, flagResolver, eventBus, resourceLimits }, + { getLogger, flagResolver, eventBus }, { segmentService, accessService, @@ -162,13 +165,14 @@ export const createFeatureToggleService = ( featureCollaboratorsReadModel, featureLinksReadModel, featureLinkService, + resourceLimitsService, }, ); return featureToggleService; }; export const createFakeFeatureToggleService = (config: IUnleashConfig) => { - const { getLogger, flagResolver, resourceLimits } = config; + const { getLogger, flagResolver } = config; const eventStore = new FakeEventStore(); const strategyStore = new FakeStrategiesStore(); const featureStrategiesStore = new FakeFeatureStrategiesStore(); @@ -206,6 +210,8 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => { const featureLinksReadModel = new FakeFeatureLinksReadModel(); const { featureLinkService } = createFakeFeatureLinkService(config); + const resourceLimitsService = new ResourceLimitsService(config); + const featureToggleService = new FeatureToggleService( { featureStrategiesStore, @@ -221,7 +227,6 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => { getLogger, flagResolver, eventBus: new EventEmitter(), - resourceLimits, }, { segmentService, @@ -235,6 +240,7 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => { featureCollaboratorsReadModel, featureLinksReadModel, featureLinkService, + resourceLimitsService, }, ); return { diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts index 658aa72926..90cd4c11b3 100644 --- a/src/lib/features/feature-toggle/feature-toggle-service.ts +++ b/src/lib/features/feature-toggle/feature-toggle-service.ts @@ -114,9 +114,9 @@ import type { IFeatureLifecycleReadModel } from '../feature-lifecycle/feature-li import { throwExceedsLimitError } from '../../error/exceeds-limit-error.js'; import type { Collaborator } from './types/feature-collaborators-read-model-type.js'; import { sortStrategies } from '../../util/sortStrategies.js'; -import type { ResourceLimitsSchema } from '../../openapi/index.js'; import type FeatureLinkService from '../feature-links/feature-link-service.js'; import type { IFeatureLink } from '../feature-links/feature-links-read-model-type.js'; +import type { ResourceLimitsService } from '../resource-limits/resource-limits-service.js'; interface IFeatureContext { featureName: string; projectId: string; @@ -161,7 +161,7 @@ export type Stores = Pick< export type Config = Pick< IUnleashConfig, - 'getLogger' | 'flagResolver' | 'eventBus' | 'resourceLimits' + 'getLogger' | 'flagResolver' | 'eventBus' >; export type ServicesAndReadModels = { @@ -176,6 +176,7 @@ export type ServicesAndReadModels = { featureCollaboratorsReadModel: IFeatureCollaboratorsReadModel; featureLinkService: FeatureLinkService; featureLinksReadModel: IFeatureLinksReadModel; + resourceLimitsService: ResourceLimitsService; }; export class FeatureToggleService { @@ -223,7 +224,7 @@ export class FeatureToggleService { private eventBus: EventEmitter; - private resourceLimits: ResourceLimitsSchema; + private resourceLimitsService: ResourceLimitsService; constructor( { @@ -236,7 +237,7 @@ export class FeatureToggleService { contextFieldStore, strategyStore, }: Stores, - { getLogger, flagResolver, eventBus, resourceLimits }: Config, + { getLogger, flagResolver, eventBus }: Config, { segmentService, accessService, @@ -249,6 +250,7 @@ export class FeatureToggleService { featureCollaboratorsReadModel, featureLinksReadModel, featureLinkService, + resourceLimitsService, }: ServicesAndReadModels, ) { this.logger = getLogger('services/feature-toggle-service.ts'); @@ -273,7 +275,7 @@ export class FeatureToggleService { this.featureLinksReadModel = featureLinksReadModel; this.featureLinkService = featureLinkService; this.eventBus = eventBus; - this.resourceLimits = resourceLimits; + this.resourceLimitsService = resourceLimitsService; } async validateFeaturesContext( @@ -396,7 +398,8 @@ export class FeatureToggleService { environment: string; featureName: string; }) { - const limit = this.resourceLimits.featureEnvironmentStrategies; + const { featureEnvironmentStrategies: limit } = + await this.resourceLimitsService.getResourceLimits(); const existingCount = ( await this.featureStrategiesStore.getStrategiesForFeatureEnv( featureEnv.projectId, @@ -412,14 +415,14 @@ export class FeatureToggleService { } } - private validateConstraintsLimit(constraints: { + private async validateConstraintsLimit(constraints: { updated: IConstraint[]; existing: IConstraint[]; }) { const { constraints: constraintsLimit, constraintValues: constraintValuesLimit, - } = this.resourceLimits; + } = await this.resourceLimitsService.getResourceLimits(); if ( constraints.updated.length > constraintsLimit && @@ -711,7 +714,7 @@ export class FeatureToggleService { const { name, title, disabled, sortOrder } = strategyConfig; let { constraints, parameters, variants } = strategyConfig; if (constraints && constraints.length > 0) { - this.validateConstraintsLimit({ + await this.validateConstraintsLimit({ updated: constraints, existing: existing?.constraints ?? [], }); @@ -1264,7 +1267,8 @@ export class FeatureToggleService { private async validateFeatureFlagLimit() { const currentFlagCount = await this.featureToggleStore.count(); - const limit = this.resourceLimits.featureFlags; + const { featureFlags: limit } = + await this.resourceLimitsService.getResourceLimits(); if (currentFlagCount >= limit) { throwExceedsLimitError(this.eventBus, { resource: 'feature flag', diff --git a/src/lib/features/project/createProjectService.ts b/src/lib/features/project/createProjectService.ts index 03051e44b6..732321c645 100644 --- a/src/lib/features/project/createProjectService.ts +++ b/src/lib/features/project/createProjectService.ts @@ -9,6 +9,7 @@ import { FavoritesService, GroupService, ProjectService, + ResourceLimitsService, } from '../../services/index.js'; import FakeGroupStore from '../../../test/fixtures/fake-group-store.js'; import FakeEventStore from '../../../test/fixtures/fake-event-store.js'; @@ -117,10 +118,13 @@ export const createProjectService = ( const privateProjectChecker = createPrivateProjectChecker(db, config); + const resourceLimitsService = new ResourceLimitsService(config); + const apiTokenService = new ApiTokenService( { apiTokenStore, environmentStore }, config, eventService, + resourceLimitsService, ); const projectReadModel = createProjectReadModel( @@ -153,6 +157,7 @@ export const createProjectService = ( eventService, privateProjectChecker, apiTokenService, + resourceLimitsService, ); }; @@ -189,10 +194,13 @@ export const createFakeProjectService = (config: IUnleashConfig) => { eventService, ); + const resourceLimitsService = new ResourceLimitsService(config); + const apiTokenService = new ApiTokenService( { apiTokenStore, environmentStore }, config, eventService, + resourceLimitsService, ); const projectReadModel = createFakeProjectReadModel(); @@ -221,6 +229,7 @@ export const createFakeProjectService = (config: IUnleashConfig) => { eventService, privateProjectChecker, apiTokenService, + resourceLimitsService, ); return { projectService, diff --git a/src/lib/features/project/project-service.ts b/src/lib/features/project/project-service.ts index b567b59aab..c4fedd42fd 100644 --- a/src/lib/features/project/project-service.ts +++ b/src/lib/features/project/project-service.ts @@ -67,10 +67,7 @@ import { calculateAverageTimeToProd } from '../feature-toggle/time-to-production import type { IProjectStatsStore } from '../../types/stores/project-stats-store-type.js'; import { uniqueByKey } from '../../util/unique.js'; import { BadDataError, PermissionError } from '../../error/index.js'; -import type { - ProjectDoraMetricsSchema, - ResourceLimitsSchema, -} from '../../openapi/index.js'; +import type { ProjectDoraMetricsSchema } from '../../openapi/index.js'; import { checkFeatureNamingData } from '../feature-naming-pattern/feature-naming-validation.js'; import type { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType.js'; import type EventService from '../events/event-service.js'; @@ -89,6 +86,7 @@ import { canGrantProjectRole } from './can-grant-project-role.js'; import { batchExecute } from '../../util/index.js'; import metricsHelper from '../../util/metrics-helper.js'; import { FUNCTION_TIME } from '../../metric-events.js'; +import type { ResourceLimitsService } from '../resource-limits/resource-limits-service.js'; type Days = number; type Count = number; @@ -148,7 +146,7 @@ export default class ProjectService { private isEnterprise: boolean; - private resourceLimits: ResourceLimitsSchema; + private resourceLimitsService: ResourceLimitsService; private eventBus: EventEmitter; @@ -193,6 +191,7 @@ export default class ProjectService { eventService: EventService, privateProjectChecker: IPrivateProjectChecker, apiTokenService: ApiTokenService, + resourceLimitsService: ResourceLimitsService, ) { this.projectStore = projectStore; this.projectOwnersReadModel = projectOwnersReadModel; @@ -213,7 +212,7 @@ export default class ProjectService { this.logger = config.getLogger('services/project-service.js'); this.flagResolver = config.flagResolver; this.isEnterprise = config.isEnterprise; - this.resourceLimits = config.resourceLimits; + this.resourceLimitsService = resourceLimitsService; this.eventBus = config.eventBus; this.projectReadModel = projectReadModel; this.onboardingReadModel = onboardingReadModel; @@ -318,7 +317,9 @@ export default class ProjectService { } async validateProjectLimit() { - const limit = Math.max(this.resourceLimits.projects, 1); + const { projects } = + await this.resourceLimitsService.getResourceLimits(); + const limit = Math.max(projects, 1); const projectCount = await this.projectStore.count(); if (projectCount >= limit) { diff --git a/src/lib/features/resource-limits/resource-limits-service.ts b/src/lib/features/resource-limits/resource-limits-service.ts new file mode 100644 index 0000000000..ff1a84d578 --- /dev/null +++ b/src/lib/features/resource-limits/resource-limits-service.ts @@ -0,0 +1,14 @@ +import type { IUnleashConfig } from '../../types/option.js'; +import type { ResourceLimitsSchema } from '../../openapi/index.js'; + +export class ResourceLimitsService { + private config: IUnleashConfig; + + constructor(config: IUnleashConfig) { + this.config = config; + } + + async getResourceLimits(): Promise { + return this.config.resourceLimits; + } +} diff --git a/src/lib/features/segment/createSegmentService.ts b/src/lib/features/segment/createSegmentService.ts index 6c7587980f..3619c6388f 100644 --- a/src/lib/features/segment/createSegmentService.ts +++ b/src/lib/features/segment/createSegmentService.ts @@ -1,5 +1,5 @@ import type { Db, IUnleashConfig } from '../../types/index.js'; -import { SegmentService } from '../../services/index.js'; +import { ResourceLimitsService, SegmentService } from '../../services/index.js'; import type { ISegmentService } from './segment-service-interface.js'; import FeatureStrategiesStore from '../feature-toggle/feature-toggle-strategies-store.js'; import SegmentStore from './segment-store.js'; @@ -51,6 +51,8 @@ export const createSegmentService = ( const eventService = createEventsService(db, config); + const resourceLimitsService = new ResourceLimitsService(config); + return new SegmentService( { segmentStore, featureStrategiesStore }, changeRequestAccessReadModel, @@ -58,6 +60,7 @@ export const createSegmentService = ( config, eventService, privateProjectChecker, + resourceLimitsService, ); }; @@ -74,6 +77,8 @@ export const createFakeSegmentService = ( const eventService = createFakeEventsService(config); + const resourceLimitsService = new ResourceLimitsService(config); + return new SegmentService( { segmentStore, featureStrategiesStore }, changeRequestAccessReadModel, @@ -81,5 +86,6 @@ export const createFakeSegmentService = ( config, eventService, privateProjectChecker, + resourceLimitsService, ); }; diff --git a/src/lib/features/segment/segment-service.ts b/src/lib/features/segment/segment-service.ts index ccf54fddbd..9c623ba6a4 100644 --- a/src/lib/features/segment/segment-service.ts +++ b/src/lib/features/segment/segment-service.ts @@ -25,11 +25,9 @@ import type { IChangeRequestAccessReadModel } from '../change-request-access-ser import type { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType.js'; import type EventService from '../events/event-service.js'; import type { IChangeRequestSegmentUsageReadModel } from '../change-request-segment-usage-service/change-request-segment-usage-read-model.js'; -import type { - ResourceLimitsSchema, - UpsertSegmentSchema, -} from '../../openapi/index.js'; +import type { UpsertSegmentSchema } from '../../openapi/index.js'; import { throwExceedsLimitError } from '../../error/exceeds-limit-error.js'; +import type { ResourceLimitsService } from '../resource-limits/resource-limits-service.js'; export class SegmentService implements ISegmentService { private logger: Logger; @@ -50,7 +48,7 @@ export class SegmentService implements ISegmentService { private privateProjectChecker: IPrivateProjectChecker; - private resourceLimits: ResourceLimitsSchema; + private resourceLimitsService: ResourceLimitsService; constructor( { @@ -62,6 +60,7 @@ export class SegmentService implements ISegmentService { config: IUnleashConfig, eventService: EventService, privateProjectChecker: IPrivateProjectChecker, + resourceLimitsService: ResourceLimitsService, ) { this.segmentStore = segmentStore; this.featureStrategiesStore = featureStrategiesStore; @@ -72,7 +71,7 @@ export class SegmentService implements ISegmentService { this.privateProjectChecker = privateProjectChecker; this.logger = config.getLogger('services/segment-service.ts'); this.flagResolver = config.flagResolver; - this.resourceLimits = config.resourceLimits; + this.resourceLimitsService = resourceLimitsService; this.config = config; } @@ -136,7 +135,8 @@ export class SegmentService implements ISegmentService { } async validateSegmentLimit() { - const limit = this.resourceLimits.segments; + const { segments: limit } = + await this.resourceLimitsService.getResourceLimits(); const segmentCount = await this.segmentStore.count(); diff --git a/src/lib/services/api-token-service.ts b/src/lib/services/api-token-service.ts index 4d7bca65ba..b2c59dc216 100644 --- a/src/lib/services/api-token-service.ts +++ b/src/lib/services/api-token-service.ts @@ -31,9 +31,9 @@ import type EventService from '../features/events/event-service.js'; import { addMinutes, isPast } from 'date-fns'; import metricsHelper from '../util/metrics-helper.js'; import { FUNCTION_TIME } from '../metric-events.js'; -import type { ResourceLimitsSchema } from '../openapi/index.js'; import { throwExceedsLimitError } from '../error/exceeds-limit-error.js'; import type EventEmitter from 'events'; +import type { ResourceLimitsService } from '../features/resource-limits/resource-limits-service.js'; const resolveTokenPermissions = (tokenType: string) => { if (tokenType === ApiTokenType.ADMIN) { @@ -73,7 +73,7 @@ export class ApiTokenService { private timer: Function; - private resourceLimits: ResourceLimitsSchema; + private resourceLimitsService: ResourceLimitsService; private eventBus: EventEmitter; @@ -84,20 +84,17 @@ export class ApiTokenService { }: Pick, config: Pick< IUnleashConfig, - | 'getLogger' - | 'authentication' - | 'flagResolver' - | 'eventBus' - | 'resourceLimits' + 'getLogger' | 'authentication' | 'flagResolver' | 'eventBus' >, eventService: EventService, + resourceLimitsService: ResourceLimitsService, ) { this.store = apiTokenStore; this.eventService = eventService; + this.resourceLimitsService = resourceLimitsService; this.environmentStore = environmentStore; this.flagResolver = config.flagResolver; this.logger = config.getLogger('/services/api-token-service.ts'); - this.resourceLimits = config.resourceLimits; if (!this.flagResolver.isEnabled('useMemoizedActiveTokens')) { // This is probably not needed because the scheduler will run it this.fetchActiveTokens(); @@ -321,7 +318,8 @@ export class ApiTokenService { private async validateApiTokenLimit() { const currentTokenCount = await this.store.count(); - const limit = this.resourceLimits.apiTokens; + const { apiTokens: limit } = + await this.resourceLimitsService.getResourceLimits(); if (currentTokenCount >= limit) { throwExceedsLimitError(this.eventBus, { resource: 'api token', diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 600b485e0e..ec8cf16315 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -171,6 +171,7 @@ import { UnknownFlagsService } from '../features/metrics/unknown-flags/unknown-f import type FeatureLinkService from '../features/feature-links/feature-link-service.js'; import { createUserService } from '../features/users/createUserService.js'; import { UiConfigService } from '../ui-config/ui-config-service.js'; +import { ResourceLimitsService } from '../features/resource-limits/resource-limits-service.js'; export const createServices = ( stores: IUnleashStores, @@ -205,6 +206,8 @@ export const createServices = ( const unknownFlagsService = new UnknownFlagsService(stores, config); + const resourceLimitsService = new ResourceLimitsService(config); + // Initialize custom metrics service const customMetricsService = new CustomMetricsService(config); @@ -283,6 +286,7 @@ export const createServices = ( config, eventService, privateProjectChecker, + resourceLimitsService, ); const clientInstanceService = new ClientInstanceService( @@ -449,6 +453,7 @@ export const createServices = ( frontendApiService, maintenanceService, sessionService, + resourceLimitsService, }); return { @@ -525,6 +530,7 @@ export const createServices = ( featureLinkService, unknownFlagsService, uiConfigService, + resourceLimitsService, }; }; @@ -581,6 +587,8 @@ export { UniqueConnectionService, FeatureLifecycleReadModel, UnknownFlagsService, + UiConfigService, + ResourceLimitsService, }; export interface IUnleashServices { @@ -657,4 +665,5 @@ export interface IUnleashServices { featureLinkService: FeatureLinkService; unknownFlagsService: UnknownFlagsService; uiConfigService: UiConfigService; + resourceLimitsService: ResourceLimitsService; } diff --git a/src/lib/ui-config/ui-config-service.ts b/src/lib/ui-config/ui-config-service.ts index d376cb6b40..ae20dbe68d 100644 --- a/src/lib/ui-config/ui-config-service.ts +++ b/src/lib/ui-config/ui-config-service.ts @@ -17,6 +17,7 @@ import { simpleAuthSettingsKey, } from '../types/settings/simple-auth-settings.js'; import version from '../util/version.js'; +import type { ResourceLimitsService } from '../features/resource-limits/resource-limits-service.js'; export class UiConfigService { private config: IUnleashConfig; @@ -33,6 +34,8 @@ export class UiConfigService { private maintenanceService: MaintenanceService; + private resourceLimitsService: ResourceLimitsService; + private flagResolver: IFlagResolver; constructor( @@ -44,6 +47,7 @@ export class UiConfigService { frontendApiService, maintenanceService, sessionService, + resourceLimitsService, }: Pick< IUnleashServices, | 'versionService' @@ -52,6 +56,7 @@ export class UiConfigService { | 'frontendApiService' | 'maintenanceService' | 'sessionService' + | 'resourceLimitsService' >, ) { this.config = config; @@ -62,6 +67,7 @@ export class UiConfigService { this.frontendApiService = frontendApiService; this.maintenanceService = maintenanceService; this.sessionService = sessionService; + this.resourceLimitsService = resourceLimitsService; } async getMaxSessionsCount(): Promise { @@ -114,7 +120,8 @@ export class UiConfigService { frontendApiOrigins: frontendSettings.frontendApiOrigins, versionInfo: await this.versionService.getVersionInfo(), prometheusAPIAvailable: this.config.prometheusApi !== undefined, - resourceLimits: this.config.resourceLimits, + resourceLimits: + await this.resourceLimitsService.getResourceLimits(), disablePasswordAuth, maintenanceMode, feedbackUriPath: this.config.feedbackUriPath,