1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

feat: make all feature toggle service write methods transactional (#9973)

This commit is contained in:
Mateusz Kwasniewski 2025-05-13 12:16:20 +02:00 committed by GitHub
parent 2a083edc14
commit 410142cb42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 164 additions and 228 deletions

View File

@ -16,10 +16,7 @@ import {
emptyResponse,
getStandardResponses,
} from '../../openapi/util/standard-responses';
import type {
TransactionCreator,
UnleashTransaction,
} from '../../db/transaction';
import type { WithTransactional } from '../../db/transaction';
import {
archivedFeaturesSchema,
type ArchivedFeaturesSchema,
@ -27,10 +24,7 @@ import {
export default class ArchiveController extends Controller {
private featureService: FeatureToggleService;
private transactionalFeatureToggleService: (
db: UnleashTransaction,
) => FeatureToggleService;
private readonly startTransaction: TransactionCreator<UnleashTransaction>;
private transactionalFeatureToggleService: WithTransactional<FeatureToggleService>;
private openApiService: OpenApiService;
constructor(
@ -45,14 +39,12 @@ export default class ArchiveController extends Controller {
| 'featureToggleService'
| 'openApiService'
>,
startTransaction: TransactionCreator<UnleashTransaction>,
) {
super(config);
this.featureService = featureToggleService;
this.openApiService = openApiService;
this.transactionalFeatureToggleService =
transactionalFeatureToggleService;
this.startTransaction = startTransaction;
this.route({
method: 'get',
@ -204,11 +196,8 @@ export default class ArchiveController extends Controller {
): Promise<void> {
const { featureName } = req.params;
await this.startTransaction(async (tx) =>
this.transactionalFeatureToggleService(tx).reviveFeature(
featureName,
req.audit,
),
await this.transactionalFeatureToggleService.transactional((service) =>
service.reviveFeature(featureName, req.audit),
);
res.status(200).end();
}

View File

@ -48,10 +48,7 @@ import type {
} from '../../services';
import { querySchema } from '../../schema/feature-schema';
import type { BatchStaleSchema } from '../../openapi/spec/batch-stale-schema';
import type {
TransactionCreator,
UnleashTransaction,
} from '../../db/transaction';
import type { WithTransactional } from '../../db/transaction';
import { BadDataError } from '../../error';
import { anonymise } from '../../util';
import { throwOnInvalidSchema } from '../../openapi/validate';
@ -116,9 +113,7 @@ export default class ProjectFeaturesController extends Controller {
private featureTagService: FeatureTagService;
private transactionalFeatureToggleService: (
db: UnleashTransaction,
) => FeatureToggleService;
private transactionalFeatureToggleService: WithTransactional<FeatureToggleService>;
private openApiService: OpenApiService;
@ -126,8 +121,6 @@ export default class ProjectFeaturesController extends Controller {
private readonly logger: Logger;
private readonly startTransaction: TransactionCreator<UnleashTransaction>;
constructor(
config: IUnleashConfig,
{
@ -136,13 +129,11 @@ export default class ProjectFeaturesController extends Controller {
transactionalFeatureToggleService,
featureTagService,
}: ProjectFeaturesServices,
startTransaction: TransactionCreator<UnleashTransaction>,
) {
super(config);
this.featureService = featureToggleService;
this.transactionalFeatureToggleService =
transactionalFeatureToggleService;
this.startTransaction = startTransaction;
this.openApiService = openApiService;
this.featureTagService = featureTagService;
this.flagResolver = config.flagResolver;
@ -658,13 +649,17 @@ export default class ProjectFeaturesController extends Controller {
): Promise<void> {
const { projectId, featureName } = req.params;
const { name, replaceGroupId } = req.body;
const created = await this.featureService.cloneFeatureToggle(
featureName,
projectId,
name,
req.audit,
replaceGroupId,
);
const created =
await this.transactionalFeatureToggleService.transactional(
(service) =>
service.cloneFeatureToggle(
featureName,
projectId,
name,
req.audit,
replaceGroupId,
),
);
this.openApiService.respondWithValidation(
201,
@ -680,14 +675,18 @@ export default class ProjectFeaturesController extends Controller {
): Promise<void> {
const { projectId } = req.params;
const created = await this.featureService.createFeatureToggle(
projectId,
{
...req.body,
description: req.body.description || undefined,
},
req.audit,
);
const created =
await this.transactionalFeatureToggleService.transactional(
(service) =>
service.createFeatureToggle(
projectId,
{
...req.body,
description: req.body.description || undefined,
},
req.audit,
),
);
this.openApiService.respondWithValidation(
201,
@ -762,15 +761,19 @@ export default class ProjectFeaturesController extends Controller {
if (data.name && data.name !== featureName) {
throw new BadDataError('Cannot change name of feature flag');
}
const created = await this.featureService.updateFeatureToggle(
projectId,
{
...data,
name: featureName,
},
featureName,
req.audit,
);
const created =
await this.transactionalFeatureToggleService.transactional(
(service) =>
service.updateFeatureToggle(
projectId,
{
...data,
name: featureName,
},
featureName,
req.audit,
),
);
this.openApiService.respondWithValidation(
200,
@ -790,12 +793,16 @@ export default class ProjectFeaturesController extends Controller {
res: Response<FeatureSchema>,
): Promise<void> {
const { projectId, featureName } = req.params;
const updated = await this.featureService.patchFeature(
projectId,
featureName,
req.body,
req.audit,
);
const updated =
await this.transactionalFeatureToggleService.transactional(
(service) =>
service.patchFeature(
projectId,
featureName,
req.body,
req.audit,
),
);
this.openApiService.respondWithValidation(
200,
res,
@ -814,13 +821,8 @@ export default class ProjectFeaturesController extends Controller {
res: Response<void>,
): Promise<void> {
const { featureName, projectId } = req.params;
await this.startTransaction(async (tx) =>
this.transactionalFeatureToggleService(tx).archiveToggle(
featureName,
req.user,
req.audit,
projectId,
),
await this.transactionalFeatureToggleService.transactional((service) =>
service.archiveToggle(featureName, req.user, req.audit, projectId),
);
res.status(202).send();
}
@ -887,14 +889,16 @@ export default class ProjectFeaturesController extends Controller {
): Promise<void> {
const { featureName, environment, projectId } = req.params;
const { shouldActivateDisabledStrategies } = req.query;
await this.featureService.updateEnabled(
projectId,
featureName,
environment,
true,
req.audit,
req.user,
shouldActivateDisabledStrategies === 'true',
await this.transactionalFeatureToggleService.transactional((service) =>
service.updateEnabled(
projectId,
featureName,
environment,
true,
req.audit,
req.user,
shouldActivateDisabledStrategies === 'true',
),
);
res.status(200).end();
}
@ -917,8 +921,8 @@ export default class ProjectFeaturesController extends Controller {
return;
}
await this.startTransaction(async (tx) =>
this.transactionalFeatureToggleService(tx).bulkUpdateEnabled(
await this.transactionalFeatureToggleService.transactional((service) =>
service.bulkUpdateEnabled(
projectId,
features,
environment,
@ -949,8 +953,8 @@ export default class ProjectFeaturesController extends Controller {
return;
}
await this.startTransaction(async (tx) =>
this.transactionalFeatureToggleService(tx).bulkUpdateEnabled(
await this.transactionalFeatureToggleService.transactional((service) =>
service.bulkUpdateEnabled(
projectId,
features,
environment,
@ -968,13 +972,15 @@ export default class ProjectFeaturesController extends Controller {
res: Response<void>,
): Promise<void> {
const { featureName, environment, projectId } = req.params;
await this.featureService.updateEnabled(
projectId,
featureName,
environment,
false,
req.audit,
req.user,
await this.transactionalFeatureToggleService.transactional((service) =>
service.updateEnabled(
projectId,
featureName,
environment,
false,
req.audit,
req.user,
),
);
res.status(200).end();
}
@ -994,12 +1000,16 @@ export default class ProjectFeaturesController extends Controller {
strategyConfig.segmentIds = [];
}
const strategy = await this.featureService.createStrategy(
strategyConfig,
{ environment, projectId, featureName },
req.audit,
req.user,
);
const strategy =
await this.transactionalFeatureToggleService.transactional(
(service) =>
service.createStrategy(
strategyConfig,
{ environment, projectId, featureName },
req.audit,
req.user,
),
);
const updatedStrategy = await this.featureService.getStrategy(
strategy.id,
@ -1031,10 +1041,8 @@ export default class ProjectFeaturesController extends Controller {
res: Response,
): Promise<void> {
const { featureName, projectId, environment } = req.params;
await this.startTransaction(async (tx) =>
this.transactionalFeatureToggleService(
tx,
).updateStrategiesSortOrder(
await this.transactionalFeatureToggleService.transactional((service) =>
service.updateStrategiesSortOrder(
{
featureName,
environment,
@ -1058,15 +1066,17 @@ export default class ProjectFeaturesController extends Controller {
req.body.segmentIds = [];
}
const updatedStrategy = await this.startTransaction(async (tx) =>
this.transactionalFeatureToggleService(tx).updateStrategy(
strategyId,
req.body,
{ environment, projectId, featureName },
req.audit,
req.user,
),
);
const updatedStrategy =
await this.transactionalFeatureToggleService.transactional(
(service) =>
service.updateStrategy(
strategyId,
req.body,
{ environment, projectId, featureName },
req.audit,
req.user,
),
);
res.status(200).json(updatedStrategy);
}
@ -1083,15 +1093,17 @@ export default class ProjectFeaturesController extends Controller {
throwOnInvalidSchema(featureStrategySchema.$id, newDocument);
const updatedStrategy = await this.startTransaction(async (tx) =>
this.transactionalFeatureToggleService(tx).updateStrategy(
strategyId,
newDocument,
{ environment, projectId, featureName },
req.audit,
req.user,
),
);
const updatedStrategy =
await this.transactionalFeatureToggleService.transactional(
(service) =>
service.updateStrategy(
strategyId,
newDocument,
{ environment, projectId, featureName },
req.audit,
req.user,
),
);
res.status(200).json(updatedStrategy);
}
@ -1115,36 +1127,17 @@ export default class ProjectFeaturesController extends Controller {
const { environment, projectId, featureName } = req.params;
const { strategyId } = req.params;
this.logger.info(strategyId);
const strategy = await this.featureService.deleteStrategy(
strategyId,
{ environment, projectId, featureName },
req.audit,
req.user,
);
res.status(200).json(strategy);
}
async updateStrategyParameter(
req: IAuthRequest<
StrategyIdParams,
any,
{ name: string; value: string | number },
any
>,
res: Response<FeatureStrategySchema>,
): Promise<void> {
const { strategyId, environment, projectId, featureName } = req.params;
const { name, value } = req.body;
const updatedStrategy =
await this.featureService.updateStrategyParameter(
strategyId,
name,
value,
{ environment, projectId, featureName },
req.audit,
const strategy =
await this.transactionalFeatureToggleService.transactional(
(service) =>
service.deleteStrategy(
strategyId,
{ environment, projectId, featureName },
req.audit,
req.user,
),
);
res.status(200).json(updatedStrategy);
res.status(200).json(strategy);
}
async updateFeaturesTags(

View File

@ -31,7 +31,6 @@ import type { OpenApiService } from '../../services';
import type { IAuthRequest } from '../../routes/unleash-types';
import { ProjectApiTokenController } from '../../routes/admin-api/project/api-token';
import ProjectArchiveController from '../../routes/admin-api/project/project-archive';
import { createKnexTransactionStarter } from '../../db/transaction';
import type { Db } from '../../db/db';
import DependentFeaturesController from '../dependent-features/dependent-features-controller';
import type { ProjectOverviewSchema } from '../../openapi/spec/project-overview-schema';
@ -222,14 +221,7 @@ export default class ProjectController extends Controller {
],
});
this.use(
'/',
new ProjectFeaturesController(
config,
services,
createKnexTransactionStarter(db),
).router,
);
this.use('/', new ProjectFeaturesController(config, services).router);
this.use('/', new DependentFeaturesController(config, services).router);
this.use(
'/',
@ -238,14 +230,7 @@ export default class ProjectController extends Controller {
this.use('/', new ProjectHealthReport(config, services).router);
this.use('/', new VariantsController(config, services).router);
this.use('/', new ProjectApiTokenController(config, services).router);
this.use(
'/',
new ProjectArchiveController(
config,
services,
createKnexTransactionStarter(db),
).router,
);
this.use('/', new ProjectArchiveController(config, services).router);
this.use('/', new ProjectInsightsController(config, services).router);
this.use('/', new ProjectStatusController(config, services).router);
this.use('/', new FeatureLifecycleController(config, services).router);

View File

@ -28,7 +28,6 @@ import InstanceAdminController from './instance-admin';
import TelemetryController from './telemetry';
import FavoritesController from './favorites';
import MaintenanceController from '../../features/maintenance/maintenance-controller';
import { createKnexTransactionStarter } from '../../db/transaction';
import type { Db } from '../../db/db';
import ExportImportController from '../../features/export-import-toggles/export-import-controller';
import { SegmentsController } from '../../features/segment/segment-controller';
@ -53,11 +52,7 @@ export class AdminApi extends Controller {
);
this.app.use(
'/archive',
new ArchiveController(
config,
services,
createKnexTransactionStarter(db),
).router,
new ArchiveController(config, services).router,
);
this.app.use(
'/strategies',

View File

@ -21,10 +21,7 @@ import {
createResponseSchema,
} from '../../../openapi';
import Controller from '../../controller';
import type {
TransactionCreator,
UnleashTransaction,
} from '../../../db/transaction';
import type { WithTransactional } from '../../../db/transaction';
const PATH = '/:projectId';
const PATH_ARCHIVE = `${PATH}/archive`;
@ -37,10 +34,7 @@ export default class ProjectArchiveController extends Controller {
private featureService: FeatureToggleService;
private transactionalFeatureToggleService: (
db: UnleashTransaction,
) => FeatureToggleService;
private readonly startTransaction: TransactionCreator<UnleashTransaction>;
private transactionalFeatureToggleService: WithTransactional<FeatureToggleService>;
private openApiService: OpenApiService;
@ -58,7 +52,6 @@ export default class ProjectArchiveController extends Controller {
| 'featureToggleService'
| 'openApiService'
>,
startTransaction: TransactionCreator<UnleashTransaction>,
) {
super(config);
this.logger = config.getLogger('/admin-api/archive.js');
@ -67,7 +60,6 @@ export default class ProjectArchiveController extends Controller {
this.flagResolver = config.flagResolver;
this.transactionalFeatureToggleService =
transactionalFeatureToggleService;
this.startTransaction = startTransaction;
this.route({
method: 'post',
@ -178,12 +170,8 @@ export default class ProjectArchiveController extends Controller {
): Promise<void> {
const { projectId } = req.params;
const { features } = req.body;
await this.startTransaction(async (tx) =>
this.transactionalFeatureToggleService(tx).reviveFeatures(
features,
projectId,
req.audit,
),
await this.transactionalFeatureToggleService.transactional((service) =>
service.reviveFeatures(features, projectId, req.audit),
);
res.status(200).end();
}
@ -195,13 +183,8 @@ export default class ProjectArchiveController extends Controller {
const { features } = req.body;
const { projectId } = req.params;
await this.startTransaction(async (tx) =>
this.transactionalFeatureToggleService(tx).archiveToggles(
features,
req.user,
req.audit,
projectId,
),
await this.transactionalFeatureToggleService.transactional((service) =>
service.archiveToggles(features, req.user, req.audit, projectId),
);
res.status(202).end();

View File

@ -69,6 +69,7 @@ import {
createFakeAccessService,
createFakeEnvironmentService,
createFakeEventsService,
createFakeFeatureToggleService,
createFakeProjectService,
createFakeUserSubscriptionsService,
createFeatureLifecycleService,
@ -133,8 +134,6 @@ import {
createFakeApiTokenService,
} from '../features/api-tokens/createApiTokenService';
import { IntegrationEventsService } from '../features/integration-events/integration-events-service';
import { FeatureCollaboratorsReadModel } from '../features/feature-toggle/feature-collaborators-read-model';
import { FakeFeatureCollaboratorsReadModel } from '../features/feature-toggle/fake-feature-collaborators-read-model';
import {
createFakePlaygroundService,
createPlaygroundService,
@ -160,8 +159,6 @@ import {
} from '../features/context/createContextService';
import { UniqueConnectionService } from '../features/unique-connection/unique-connection-service';
import { createFakeFeatureLinkService } from '../features/feature-links/createFeatureLinkService';
import { FeatureLinksReadModel } from '../features/feature-links/feature-links-read-model';
import { FakeFeatureLinksReadModel } from '../features/feature-links/fake-feature-links-read-model';
import { UnknownFlagsService } from '../features/metrics/unknown-flags/unknown-flags-service';
export const createServices = (
@ -287,26 +284,6 @@ export const createServices = (
? createFeatureSearchService(config)(db)
: createFakeFeatureSearchService(config);
const featureCollaboratorsReadModel = db
? new FeatureCollaboratorsReadModel(db)
: new FakeFeatureCollaboratorsReadModel();
const featureLinksReadModel = db
? new FeatureLinksReadModel(db, config.eventBus)
: new FakeFeatureLinksReadModel();
const featureToggleService = new FeatureToggleService(stores, config, {
segmentService,
accessService,
eventService,
changeRequestAccessReadModel,
privateProjectChecker,
dependentFeaturesReadModel,
dependentFeaturesService,
featureLifecycleReadModel,
featureCollaboratorsReadModel,
featureLinksReadModel,
});
const transactionalEnvironmentService = db
? withTransactional(createEnvironmentService(config), db)
: withFakeTransactional(createFakeEnvironmentService(config));
@ -346,8 +323,12 @@ export const createServices = (
const importService = db
? withTransactional(deferredExportImportTogglesService(config), db)
: withFakeTransactional(createFakeExportImportTogglesService(config));
const transactionalFeatureToggleService = (txDb: Knex.Transaction) =>
createFeatureToggleService(txDb, config);
const featureToggleService = db
? withTransactional((db) => createFeatureToggleService(db, config), db)
: withFakeTransactional(
createFakeFeatureToggleService(config).featureToggleService,
);
const transactionalFeatureToggleService = featureToggleService;
const transactionalGroupService = (txDb: Knex.Transaction) =>
createGroupService(txDb, config);
const userSplashService = new UserSplashService(stores, config);

View File

@ -114,9 +114,7 @@ export interface IUnleashServices {
configurationRevisionService: ConfigurationRevisionService;
schedulerService: SchedulerService;
eventAnnouncerService: EventAnnouncerService;
transactionalFeatureToggleService: (
db: Knex.Transaction,
) => FeatureToggleService;
transactionalFeatureToggleService: WithTransactional<FeatureToggleService>;
transactionalGroupService: (db: Knex.Transaction) => GroupService;
privateProjectChecker: IPrivateProjectChecker;
dependentFeaturesService: DependentFeaturesService;

View File

@ -35,7 +35,11 @@ test('should not allow to create feature flags in maintenance mode', async () =>
});
test('maintenance mode is off by default', async () => {
const appWithMaintenanceMode = await setupApp(db.stores);
const appWithMaintenanceMode = await setupAppWithCustomConfig(
db.stores,
{},
db.rawDatabase,
);
return appWithMaintenanceMode.request
.post('/api/admin/projects/default/features')

View File

@ -11,14 +11,18 @@ let db: ITestDb;
beforeAll(async () => {
db = await dbInit('project_feature_variants_api_sunset', getLogger);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
enableLegacyVariants: false,
app = await setupAppWithCustomConfig(
db.stores,
{
experimental: {
flags: {
strictSchemaValidation: true,
enableLegacyVariants: false,
},
},
},
});
db.rawDatabase,
);
});
afterAll(async () => {

View File

@ -12,14 +12,18 @@ let db: ITestDb;
beforeAll(async () => {
db = await dbInit('project_feature_variants_api_serial', getLogger);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
strictSchemaValidation: true,
enableLegacyVariants: true,
app = await setupAppWithCustomConfig(
db.stores,
{
experimental: {
flags: {
strictSchemaValidation: true,
enableLegacyVariants: true,
},
},
},
});
db.rawDatabase,
);
});
afterAll(async () => {

View File

@ -19,7 +19,7 @@ beforeAll(async () => {
getLogger.setMuteError(true);
db = await dbInit('user_pat', getLogger);
patStore = db.stores.patStore;
app = await setupAppWithAuth(db.stores);
app = await setupAppWithAuth(db.stores, {}, db.rawDatabase);
await app.request
.post(`/auth/demo/login`)