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
17 changes: 13 additions & 4 deletions packages/backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ npm run migration:run
- `@Authorized()` decorator (from routing-controllers) enforces auth on controllers
- `@CurrentUser()` injects the authenticated `User` object into handler params

## Deletion Policy

`DeletionPolicyService` is the one role/state/usage policy for the user-facing deletion APIs (`DELETE /experiments/:id`, `/flags/:id`, `/segments/:segmentId` and the three `POST /…/batch-delete` routes). Deletable experiment states (`DELETABLE_EXPERIMENT_STATES`) live in `upgrade_types` so the frontend checks the same list.

- The role is checked per request (`assertRoleCanDelete` → `PermissionService.assertPermitted(user, resource, 'delete')` → 403) before any target is locked. Role permissions for every resource and action live in the shared `ROLE_PERMISSIONS` table in `upgrade_types`, which the frontend also uses; enforce other actions through `PermissionService` rather than per-action role lists. State and usage rules stay per action (as `DeletionPolicyService` does for deletion).
- State and usage are checked by `getRefusalReason()` on fields read by `DeletionRepository.findForDeletion()` under the target's `pessimistic_write` lock, in the deletion transaction, never from an earlier unlocked read. Segment usage (`isSegmentInUse`) is evaluated for that one segment and mirrors the Used status from `SegmentService.getSegmentStatus()`.
- Single routes pass `singleDeletionTransaction()` to the entity service and return 409 (`SERVER_ERROR.DELETION_NOT_ALLOWED`) on refusal. Batch items are reported `refused` with a policy `DeletionReasonCode`, and the batch continues.
- Internal deletions (create rollback, private-list removal via `deleteList`) call the entity services without the policy executor, deliberately.

## Environment

Copy `.env.example` to `.env` for local dev. Never commit `.env` files. Key sections: `app`, `db`, `google`, `swagger`, `aws`. See `src/env.ts` for the full typed schema.
Expand Down Expand Up @@ -105,10 +114,10 @@ Segment inclusion/exclusion for **feature flags** is precomputed and stored flat
| Private list added to a shared segment | `SegmentService.addList` → `scheduleRecomputeForSegment` |
| Private list removed from a shared segment | `SegmentService.deleteList` → `scheduleRecomputeForSegment` |
| Segment members/structure updated | `SegmentService.addSegmentDataWithPipeline` → `scheduleRecomputeForSegment` |
| Segment deleted entirely | `SegmentService.deleteSegment` → `withRecompute` for single deletion; batch deletion collects affected flag IDs after the target lock and **before** deletion, then awaits recomputation **after** commit |
| Segment deleted entirely | `SegmentService.deleteSegment` → the single and batch deletion routes collect affected flag IDs after the target lock and **before** deletion, then await recomputation **after** commit; internal callers (`deleteList`) use `withRecompute` |
| Server startup | `app.ts` → `backfillMissingFlags` — backfills any flag with no row |

Ordinary write paths use **fire-and-forget** recomputation. The `import*` paths await `recomputeForFlag` so "import complete" means the rows are ready. Batch segment deletion also awaits post-commit recomputation for both flags and experiments before processing the next segment. `SegmentService.deleteSegment` selects this awaited branch when supplied with the batch transaction executor; ordinary callers retain background recomputation.
Ordinary write paths use **fire-and-forget** recomputation. The `import*` paths await `recomputeForFlag` so "import complete" means the rows are ready. Segment deletion through the single and batch routes also awaits post-commit recomputation for both flags and experiments (a batch finishes it before processing the next segment). `SegmentService.deleteSegment` selects this awaited branch when supplied with a deletion transaction executor; internal callers retain background recomputation.

### Key invariant

Expand Down Expand Up @@ -143,10 +152,10 @@ Experiment join tables (`ExperimentSegmentInclusion` / `ExperimentSegmentExclusi
| Experiment lists imported | `ExperimentService.importExperimentLists` → `await recomputeForExperiment` after the import transaction commits |
| Experiment context changed (deletes all its lists) | `ExperimentService.updateExperimentInDB` → `scheduleRecomputeForExperiments` after commit (recomputes to empty; `deleteAllListsFromExperiment` deletes the private segments directly, so the precomputed row would otherwise keep stale IDs) |
| Shared segment members/structure changed | `SegmentService.addList` / `deleteList` / `addSegmentDataWithPipeline` → `scheduleRecomputeForSegment` for **both** the flag and experiment services |
| Segment deleted entirely | `SegmentService.deleteSegment` → collects affected experiment IDs **before** deletion and recomputes **after** commit; single deletion schedules background work, while batch deletion collects owners after the target lock and awaits recomputation |
| Segment deleted entirely | `SegmentService.deleteSegment` → collects affected experiment IDs **before** deletion and recomputes **after** commit; the single and batch deletion routes collect owners after the target lock and await recomputation, while internal callers schedule background work |
| Server startup | `app.ts` → `backfillExperimentPrecomputedSegments` (guarded by `.catch` — a missing table never crashes startup) |

Same invariant as feature flags: recompute **after** the change commits; for deletes, collect affected experiment IDs **before**. Write-path recomputes are fire-and-forget except the `create` / import paths and batch segment deletion, which await recomputation. A batch finishes each segment's post-commit updates before deleting the next segment.
Same invariant as feature flags: recompute **after** the change commits; for deletes, collect affected experiment IDs **before**. Write-path recomputes are fire-and-forget except the `create` / import paths and segment deletion through the single and batch deletion routes, which await recomputation. A batch finishes each segment's post-commit updates before deleting the next segment.

### INCLUDE_ALL semantics (resolved)

Expand Down
18 changes: 15 additions & 3 deletions packages/backend/src/api/controllers/ExperimentController.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BatchDeleteResult } from 'upgrade_types';
import { Inject } from 'typedi';
import { BatchDeleteService } from '../services/BatchDeleteService';
import { DeletionPolicyService } from '../services/DeletionPolicyService';
import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator';
import {
Body,
Expand Down Expand Up @@ -669,15 +670,16 @@ export class ExperimentController {
public cacheService: CacheService,
public thompsonSamplingCrudService: ThompsonSamplingExperimentCrudService,
public adaptiveExperimentConfigDispatcher: AdaptiveExperimentConfigDispatcherService,
@Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService
@Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService,
@Inject(() => DeletionPolicyService) private deletionPolicyService: DeletionPolicyService
) {}

/**
* @swagger
* /experiments/batch-delete:
* post:
* summary: Delete the selected experiments
* description: Deletes selected experiments sequentially regardless of state, skipping missing items. Execution failures stop the remaining items. Returns one result per ID.
* description: Deletes selected experiments sequentially. Requires the Admin or Creator role. Missing items are skipped; experiments that are not Draft, Inactive, Completed or Archived are refused and skipped. Execution failures stop the remaining items. Returns one result per ID.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

"Execution failures stop the remaining items."

I guess this is pre-existing and i didn't catch it before, but I don't think one error should stop the execution, other items could delete successfully and there's no reason i can think of to stop that from happening, is there? It can return the non-delete reason and the UI can show the reason for items that didn't delete.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It's unlikely that this will ever happen from a UI interaction, since batch deletes are disabled as long as at least one item isn't deletable. But, yeah, I figured for an API call (like one made by load tests) it makes sense to delete what's deletable and report what isn't.

* tags:
* - Experiments
* parameters:
Expand All @@ -695,6 +697,8 @@ export class ExperimentController {
* description: Expected a nonempty array of unique UUIDs.
* '401':
* description: AuthorizationRequiredError
* '403':
* description: The current user's role cannot delete experiments. Nothing is deleted.
*/
@Post('/batch-delete')
public batchDelete(
Expand Down Expand Up @@ -1182,8 +1186,12 @@ export class ExperimentController {
* description: ExperimentId should be a valid UUID.
* '401':
* description: AuthorizationRequiredError
* '403':
* description: The current user's role cannot delete experiments
* '404':
* description: Not found error
* '409':
* description: The experiment is Running, Paused, Preview or Scheduled and was not deleted
* '500':
* description: id should be of type UUID
*/
Expand All @@ -1196,7 +1204,11 @@ export class ExperimentController {
): Promise<Experiment | undefined> {
request.logger.child({ user: currentUser });

const experiment = await this.experimentService.delete(id, currentUser, { logger: request.logger });
this.deletionPolicyService.assertRoleCanDelete('experiments', currentUser);
const experiment = await this.experimentService.delete(id, currentUser, {
logger: request.logger,
executeTransaction: this.deletionPolicyService.singleDeletionTransaction('experiments', id),
});

if (!experiment) {
throw new NotFoundException('Experiment not found.');
Expand Down
20 changes: 17 additions & 3 deletions packages/backend/src/api/controllers/FeatureFlagController.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BatchDeleteResult } from 'upgrade_types';
import { Inject } from 'typedi';
import { BatchDeleteService } from '../services/BatchDeleteService';
import { DeletionPolicyService } from '../services/DeletionPolicyService';
import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator';
import {
JsonController,
Expand Down Expand Up @@ -161,15 +162,16 @@ export class FeatureFlagsController {
constructor(
public featureFlagService: FeatureFlagService,
public experimentUserService: ExperimentUserService,
@Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService
@Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService,
@Inject(() => DeletionPolicyService) private deletionPolicyService: DeletionPolicyService
) {}

/**
* @swagger
* /flags/batch-delete:
* post:
* summary: Delete the selected flags
* description: Deletes selected feature flags sequentially regardless of status, skipping missing items. Execution failures stop the remaining items. Returns one result per ID.
* description: Deletes selected feature flags sequentially. Requires the Admin or Creator role. Missing items are skipped; Enabled flags are refused and skipped. Execution failures stop the remaining items. Returns one result per ID.
* tags:
* - Feature Flags
* parameters:
Expand All @@ -187,6 +189,8 @@ export class FeatureFlagsController {
* description: Expected a nonempty array of unique UUIDs.
* '401':
* description: AuthorizationRequiredError
* '403':
* description: The current user's role cannot delete feature flags. Nothing is deleted.
*/
@Post('/batch-delete')
public batchDelete(
Expand Down Expand Up @@ -464,8 +468,12 @@ export class FeatureFlagsController {
* responses:
* '200':
* description: Delete Feature flag By Id
* '403':
* description: The current user's role cannot delete feature flags
* '404':
* description: Feature flag not found
* '409':
* description: The feature flag is Enabled and was not deleted
*/

@Delete('/:id')
Expand All @@ -474,7 +482,13 @@ export class FeatureFlagsController {
@CurrentUser() currentUser: UserDTO,
@Req() request: AppRequest
): Promise<FeatureFlag> {
const featureFlag = await this.featureFlagService.delete(id, currentUser, request.logger);
this.deletionPolicyService.assertRoleCanDelete('flags', currentUser);
const featureFlag = await this.featureFlagService.delete(
id,
currentUser,
request.logger,
this.deletionPolicyService.singleDeletionTransaction('flags', id)
);

if (!featureFlag) {
throw new NotFoundException('Feature flag not found.');
Expand Down
26 changes: 20 additions & 6 deletions packages/backend/src/api/controllers/SegmentController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { UserDTO } from '../DTO/UserDTO';
import { BatchDeleteResult } from 'upgrade_types';
import { Inject } from 'typedi';
import { BatchDeleteService } from '../services/BatchDeleteService';
import { DeletionPolicyService } from '../services/DeletionPolicyService';
import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator';
import {
JsonController,
Expand Down Expand Up @@ -64,10 +65,10 @@ interface SegmentPaginationInfo extends PaginationResponse {
* format: uuid
* outcome:
* type: string
* enum: [deleted, not_found, failed, unknown, not_attempted]
* enum: [deleted, not_found, refused, failed, unknown, not_attempted]
* reasonCode:
* type: string
* enum: [not_found, delete_failed, lock_timeout, outcome_unknown, post_delete_failed]
* enum: [not_found, delete_failed, lock_timeout, outcome_unknown, post_delete_failed, state_not_deletable, segment_in_use, global_exclude_protected]
* BatchDeleteResult:
* type: object
* required: [results]
Expand Down Expand Up @@ -273,15 +274,16 @@ interface SegmentPaginationInfo extends PaginationResponse {
export class SegmentController {
constructor(
public segmentService: SegmentService,
@Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService
@Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService,
@Inject(() => DeletionPolicyService) private deletionPolicyService: DeletionPolicyService
) {}

/**
* @swagger
* /segments/batch-delete:
* post:
* summary: Delete the selected segments
* description: Deletes selected segments sequentially using the existing deletion workflow, skipping missing items. Execution failures stop the remaining items. Returns one result per ID.
* description: Deletes selected segments sequentially using the existing deletion workflow. Requires the Admin, Creator or User Manager role. Missing items are skipped; Used and global-exclude segments are refused and skipped. Execution failures stop the remaining items. Returns one result per ID.
* tags:
* - Segment
* parameters:
Expand All @@ -299,6 +301,8 @@ export class SegmentController {
* description: Expected a nonempty array of unique UUIDs.
* '401':
* description: AuthorizationRequiredError
* '403':
* description: The current user's role cannot delete segments. Nothing is deleted.
*/
@Post('/batch-delete')
public batchDelete(
Expand Down Expand Up @@ -664,15 +668,25 @@ export class SegmentController {
* $ref: '#/definitions/segmentResponse'
* '401':
* description: Authorization Required Error
* '403':
* description: The current user's role cannot delete segments
* '409':
* description: The segment is Used or is a global-exclude segment, and was not deleted
* '500':
* description: Internal Server Error, SegmentId is not valid
*/
@Delete('/:segmentId')
public deleteSegment(
public async deleteSegment(
@Params({ validate: true }) { segmentId }: SegmentIdValidator,
@CurrentUser() currentUser: UserDTO,
@Req() request: AppRequest
): Promise<Segment> {
return this.segmentService.deleteSegment(segmentId, request.logger);
this.deletionPolicyService.assertRoleCanDelete('segments', currentUser);
return this.segmentService.deleteSegment(
segmentId,
request.logger,
this.deletionPolicyService.singleDeletionTransaction('segments', segmentId)
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export class ErrorHandlerMiddleware implements ExpressErrorMiddlewareInterface {
type = SERVER_ERROR.EMAIL_SEND_ERROR;
message = errorMessage;
break;
case SERVER_ERROR.DELETION_NOT_ALLOWED:
type = SERVER_ERROR.DELETION_NOT_ALLOWED;
message = errorMessage;
break;
default:
switch (error.httpCode) {
case 400:
Expand Down
67 changes: 63 additions & 4 deletions packages/backend/src/api/repositories/DeletionRepository.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,84 @@
import { EntityManager, Repository } from 'typeorm';
import { BatchDeleteEntity } from 'upgrade_types';
import {
BatchDeleteEntity,
EXPERIMENT_STATE,
FEATURE_FLAG_STATUS,
SEGMENT_TYPE,
STANDARD_LIST_TYPE,
} from 'upgrade_types';
import { EntityRepository } from '../../typeorm-typedi-extensions';
import { Experiment } from '../models/Experiment';
import { FeatureFlag } from '../models/FeatureFlag';
import { Segment } from '../models/Segment';
import repositoryError from './utils/repositoryError';

/** The locked fields the deletion policy evaluates. Only the fields for the target's entity type are set. */
export interface DeletionTarget {
id: string;
state?: EXPERIMENT_STATE;
status?: FEATURE_FLAG_STATUS;
type?: SEGMENT_TYPE;
}

const policyFields = {
experiments: { id: true, state: true },
flags: { id: true, status: true },
segments: { id: true, type: true },
};

/** All reads and locks use the caller's deletion transaction, never the repository's default manager. */
@EntityRepository()
export class DeletionRepository extends Repository<DeletionRepository> {
public async findForDeletion(
entity: BatchDeleteEntity,
id: string,
manager: EntityManager
): Promise<{ id: string } | null> {
): Promise<DeletionTarget | null> {
const target = { experiments: Experiment, flags: FeatureFlag, segments: Segment }[entity];
return manager
.getRepository<{ id: string }>(target)
.findOne({ where: { id }, select: { id: true }, lock: { mode: 'pessimistic_write' } })
.getRepository<DeletionTarget>(target)
.findOne({ where: { id }, select: policyFields[entity], lock: { mode: 'pessimistic_write' } })
.catch((errorMsg: any) => {
throw repositoryError('DeletionRepository', 'findForDeletion', { entity, id }, errorMsg);
});
}

/**
* Whether a segment has the Used status that `SegmentService.getSegmentStatus()` reports, evaluated for one
* segment instead of every segment. A segment is Used when it, or any segment that contains it, is a list of a
* non-archived experiment or of any feature flag, or when it is a member of a segment-type list that belongs to
* another segment.
*/
public async isSegmentInUse(segmentId: string, manager: EntityManager): Promise<boolean> {
const rows: { used: boolean }[] = await manager
.query(
`WITH RECURSIVE containing ("id") AS (
SELECT $1::uuid
UNION
SELECT sfs."parentSegmentId" FROM "segment_for_segment" sfs JOIN containing c ON sfs."childSegmentId" = c."id"
)
SELECT (
EXISTS (
SELECT 1 FROM "experiment_segment_inclusion" esi JOIN "experiment" e ON e."id" = esi."experimentId"
WHERE esi."segmentId" IN (SELECT "id" FROM containing) AND e."state" <> $2
)
Comment thread
Copilot marked this conversation as resolved.
Resolved
OR EXISTS (
SELECT 1 FROM "experiment_segment_exclusion" ese JOIN "experiment" e ON e."id" = ese."experimentId"
WHERE ese."segmentId" IN (SELECT "id" FROM containing) AND e."state" <> $2
)
OR EXISTS (SELECT 1 FROM "feature_flag_segment_inclusion" WHERE "segmentId" IN (SELECT "id" FROM containing))
OR EXISTS (SELECT 1 FROM "feature_flag_segment_exclusion" WHERE "segmentId" IN (SELECT "id" FROM containing))
OR EXISTS (
SELECT 1 FROM "segment_for_segment" m JOIN "segment" l ON l."id" = m."parentSegmentId"
WHERE m."childSegmentId" = $1 AND l."listType" = $3
AND EXISTS (SELECT 1 FROM "segment_for_segment" o WHERE o."childSegmentId" = l."id")
)
) AS "used"`,
[segmentId, EXPERIMENT_STATE.ARCHIVED, STANDARD_LIST_TYPE.SEGMENT]
)
.catch((errorMsg: any) => {
throw repositoryError('DeletionRepository', 'isSegmentInUse', { segmentId }, errorMsg);
});
return rows[0]?.used === true;
}
}
Loading
Loading