Skip to content

Commit

Permalink
Feature: Experiment Archive State (#987)
Browse files Browse the repository at this point in the history
* working archive feature frontend changes

* ignoring storing logs for archived experiments

* add few more missing readonly properties for archive state

* missed readonly for archive state

* Create API and database for archive

* resolved failing test cases for archive state

* import change

* not allowing archived state change from enrolling state

* merge analysis and archieve api

* added remaining file

* fix peer review comments for archive state

* Solve review comments

---------

Co-authored-by: RidhamShah <[email protected]>
Co-authored-by: danoswaltCL <[email protected]>
  • Loading branch information
3 people authored Oct 26, 2023
1 parent ffede04 commit 58f1e63
Show file tree
Hide file tree
Showing 22 changed files with 373 additions and 58 deletions.
18 changes: 18 additions & 0 deletions backend/packages/Upgrade/src/api/models/ArchivedStats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Entity, PrimaryColumn, Column, OneToOne, JoinColumn } from 'typeorm';
import { IsNotEmpty } from 'class-validator';
import { BaseModel } from './base/BaseModel';
import { Query } from './Query';

@Entity()
export class ArchivedStats extends BaseModel {
@PrimaryColumn('uuid')
public id: string;

@IsNotEmpty()
@Column('jsonb')
public result: object;

@OneToOne(() => Query, (query) => query.archivedStats, { onDelete: 'CASCADE' })
@JoinColumn()
public query: Query;
}
8 changes: 7 additions & 1 deletion backend/packages/Upgrade/src/api/models/Query.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Entity, ManyToOne, Column, PrimaryColumn } from 'typeorm';
import { Entity, ManyToOne, Column, PrimaryColumn, OneToOne } from 'typeorm';
import { BaseModel } from './base/BaseModel';
import { Metric } from './Metric';
import { Experiment } from './Experiment';
import { IsDefined } from 'class-validator';
import { REPEATED_MEASURE } from 'upgrade_types';
import { ArchivedStats } from './ArchivedStats';
import { Type } from 'class-transformer';

@Entity()
export class Query extends BaseModel {
Expand All @@ -23,6 +25,10 @@ export class Query extends BaseModel {
@ManyToOne(() => Experiment, (experiment) => experiment.queries, { onDelete: 'CASCADE' })
public experiment: Experiment;

@OneToOne(() => ArchivedStats, (archivedStats) => archivedStats.query, { onDelete: 'CASCADE' })
@Type(() => ArchivedStats)
public archivedStats: ArchivedStats;

@Column({
type: 'enum',
enum: REPEATED_MEASURE,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { EntityRepository, Repository } from 'typeorm';
import { ArchivedStats } from '../models/ArchivedStats';
import repositoryError from './utils/repositoryError';

@EntityRepository(ArchivedStats)
export class ArchivedStatsRepository extends Repository<ArchivedStats> {
public async saveRawJson(
rawDataArray: Array<Omit<ArchivedStats, 'createdAt' | 'updatedAt' | 'versionNumber'>>
): Promise<ArchivedStats> {
const result = await this.createQueryBuilder('ArchivedStats')
.insert()
.into(ArchivedStats)
.values(rawDataArray)
.onConflict(`DO NOTHING`)
.returning('*')
.execute()
.catch((errorMsg: any) => {
const errorMsgString = repositoryError(this.constructor.name, 'saveRawJson', { rawDataArray }, errorMsg);
throw errorMsgString;
});

return result.raw;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Metric } from '../models/Metric';
import { EntityRepository, Repository } from 'typeorm';
import repositoryError from './utils/repositoryError';
import { EXPERIMENT_STATE } from 'upgrade_types';

@EntityRepository(Metric)
export class MetricRepository extends Repository<Metric> {
Expand Down Expand Up @@ -32,7 +33,9 @@ export class MetricRepository extends Repository<Metric> {
public async findMetricsWithQueries(ids: string[]): Promise<Metric[]> {
return this.createQueryBuilder('metrics')
.innerJoin('metrics.queries', 'queries')
.innerJoin('queries.experiment', 'experiment')
.where('key IN (:...ids)', { ids })
.andWhere('experiment.state NOT IN (:...archived)', { archived: [EXPERIMENT_STATE.ARCHIVED] })
.getMany()
.catch((errorMsg: any) => {
const errorMsgString = repositoryError(this.constructor.name, 'findMetricsWithQueries', { ids }, errorMsg);
Expand Down
27 changes: 25 additions & 2 deletions backend/packages/Upgrade/src/api/services/ExperimentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ import {
import { ConditionPayloadDTO } from '../DTO/ConditionPayloadDTO';
import { FactorDTO } from '../DTO/FactorDTO';
import { LevelDTO } from '../DTO/LevelDTO';
import { QueryService } from './QueryService';
import { ArchivedStats } from '../models/ArchivedStats';
import { ArchivedStatsRepository } from '../repositories/ArchivedStatsRepository';

@Service()
export class ExperimentService {
Expand All @@ -93,10 +96,12 @@ export class ExperimentService {
@OrmRepository() private factorRepository: FactorRepository,
@OrmRepository() private levelRepository: LevelRepository,
@OrmRepository() private levelCombinationElementsRepository: LevelCombinationElementRepository,
@OrmRepository() private archivedStatsRepository: ArchivedStatsRepository,
public previewUserService: PreviewUserService,
public segmentService: SegmentService,
public scheduledJobService: ScheduledJobService,
public errorService: ErrorService
public errorService: ErrorService,
public queryService: QueryService
) {}

public async find(logger?: UpgradeLogger): Promise<ExperimentDTO[]> {
Expand Down Expand Up @@ -384,7 +389,7 @@ export class ExperimentService {
): Promise<Experiment> {
const oldExperiment = await this.experimentRepository.findOne(
{ id: experimentId },
{ relations: ['stateTimeLogs'] }
{ relations: ['stateTimeLogs', 'queries', 'queries.metric'] }
);

if (
Expand All @@ -394,6 +399,24 @@ export class ExperimentService {
await this.populateExclusionTable(experimentId, state, logger);
}

if (state === EXPERIMENT_STATE.ARCHIVED) {
const queryIds = oldExperiment.queries.map((query) => query.id);
const results = await this.queryService.analyze(queryIds, logger);
const archivedStatsData: Array<Omit<ArchivedStats, 'createdAt' | 'updatedAt' | 'versionNumber'>> = results.map(
(result) => {
const queryId = result.id;
delete result.id;
const archivedStats: Partial<ArchivedStats> = {
id: uuid(),
result: result,
query: queryId,
};
return archivedStats;
}
);
await this.archivedStatsRepository.saveRawJson(archivedStatsData);
}

let data: AuditLogData = {
experimentId,
experimentName: oldExperiment.name,
Expand Down
22 changes: 21 additions & 1 deletion backend/packages/Upgrade/src/api/services/QueryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import { OrmRepository } from 'typeorm-typedi-extensions';
import { QueryRepository } from '../repositories/QueryRepository';
import { Query } from '../models/Query';
import { LogRepository } from '../repositories/LogRepository';
import { EXPERIMENT_TYPE, SERVER_ERROR } from 'upgrade_types';
import { EXPERIMENT_STATE, EXPERIMENT_TYPE, SERVER_ERROR } from 'upgrade_types';
import { ErrorService } from './ErrorService';
import { ExperimentError } from '../models/ExperimentError';
import { UpgradeLogger } from '../../lib/logger/UpgradeLogger';
import { Experiment } from '../models/Experiment';
import { ArchivedStatsRepository } from '../repositories/ArchivedStatsRepository';
import { In } from 'typeorm';

interface queryResult {
conditionId?: string;
Expand All @@ -21,6 +23,7 @@ export class QueryService {
constructor(
@OrmRepository() private queryRepository: QueryRepository,
@OrmRepository() private logRepository: LogRepository,
@OrmRepository() private archivedStatsRepository: ArchivedStatsRepository,
public errorService: ErrorService
) {}

Expand All @@ -35,6 +38,17 @@ export class QueryService {
});
}

public async getArchivedStats(queryIds: string[], logger: UpgradeLogger): Promise<any> {
logger.info({ message: `Get archivedStats of query with queryIds ${queryIds}` });
const archiveData = await this.archivedStatsRepository.find({
relations: ['query'],
where: { query: In(queryIds) },
});
return archiveData.map((data) => {
return { ...data.result, id: data.query.id };
});
}

public async analyze(queryIds: string[], logger: UpgradeLogger): Promise<any> {
logger.info({ message: `Get analysis of query with queryIds ${queryIds}` });
const promiseArray = queryIds.map((queryId) =>
Expand All @@ -52,6 +66,12 @@ export class QueryService {

const promiseResult = await Promise.all(promiseArray);
const experiments: Experiment[] = [];

// checks for archieve state experiment
if (promiseResult[0].experiment?.state === EXPERIMENT_STATE.ARCHIVED) {
return this.getArchivedStats(queryIds, logger);
}

const analyzePromise = promiseResult.map((query) => {
experiments.push(query.experiment);
if (query.experiment?.type === EXPERIMENT_TYPE.FACTORIAL) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class archivedState1692936809279 implements MigrationInterface {
name = 'archivedState1692936809279';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "archived_stats" ("createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "versionNumber" integer NOT NULL, "id" uuid NOT NULL, "result" jsonb NOT NULL, "queryId" uuid, CONSTRAINT "REL_df18fe5ea7298a1dc7bfb33b06" UNIQUE ("queryId"), CONSTRAINT "PK_afc8a335c8d9d48cefaf1aa2c09" PRIMARY KEY ("id"))`
);
await queryRunner.query(
`ALTER TYPE "public"."state_time_log_fromstate_enum" RENAME TO "state_time_log_fromstate_enum_old"`
);
await queryRunner.query(
`CREATE TYPE "public"."state_time_log_fromstate_enum" AS ENUM('inactive', 'preview', 'scheduled', 'enrolling', 'enrollmentComplete', 'cancelled', 'archived')`
);
await queryRunner.query(
`ALTER TABLE "public"."state_time_log" ALTER COLUMN "fromState" TYPE "public"."state_time_log_fromstate_enum" USING "fromState"::"text"::"public"."state_time_log_fromstate_enum"`
);
await queryRunner.query(`DROP TYPE "public"."state_time_log_fromstate_enum_old"`);
await queryRunner.query(
`ALTER TYPE "public"."state_time_log_tostate_enum" RENAME TO "state_time_log_tostate_enum_old"`
);
await queryRunner.query(
`CREATE TYPE "public"."state_time_log_tostate_enum" AS ENUM('inactive', 'preview', 'scheduled', 'enrolling', 'enrollmentComplete', 'cancelled', 'archived')`
);
await queryRunner.query(
`ALTER TABLE "public"."state_time_log" ALTER COLUMN "toState" TYPE "public"."state_time_log_tostate_enum" USING "toState"::"text"::"public"."state_time_log_tostate_enum"`
);
await queryRunner.query(`DROP TYPE "public"."state_time_log_tostate_enum_old"`);
await queryRunner.query(`ALTER TYPE "public"."experiment_state_enum" RENAME TO "experiment_state_enum_old"`);
await queryRunner.query(
`CREATE TYPE "public"."experiment_state_enum" AS ENUM('inactive', 'preview', 'scheduled', 'enrolling', 'enrollmentComplete', 'cancelled', 'archived')`
);
await queryRunner.query(`ALTER TABLE "public"."experiment" ALTER COLUMN "state" DROP DEFAULT`);
await queryRunner.query(
`ALTER TABLE "public"."experiment" ALTER COLUMN "state" TYPE "public"."experiment_state_enum" USING "state"::"text"::"public"."experiment_state_enum"`
);
await queryRunner.query(`ALTER TABLE "public"."experiment" ALTER COLUMN "state" SET DEFAULT 'inactive'`);
await queryRunner.query(`DROP TYPE "public"."experiment_state_enum_old"`);
await queryRunner.query(
`ALTER TABLE "archived_stats" ADD CONSTRAINT "FK_df18fe5ea7298a1dc7bfb33b06f" FOREIGN KEY ("queryId") REFERENCES "query"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "archived_stats" DROP CONSTRAINT "FK_df18fe5ea7298a1dc7bfb33b06f"`);
await queryRunner.query(
`CREATE TYPE "public"."experiment_state_enum_old" AS ENUM('cancelled', 'enrolling', 'enrollmentComplete', 'inactive', 'preview', 'scheduled')`
);
await queryRunner.query(`ALTER TABLE "public"."experiment" ALTER COLUMN "state" DROP DEFAULT`);
await queryRunner.query(
`ALTER TABLE "public"."experiment" ALTER COLUMN "state" TYPE "public"."experiment_state_enum_old" USING "state"::"text"::"public"."experiment_state_enum_old"`
);
await queryRunner.query(`ALTER TABLE "public"."experiment" ALTER COLUMN "state" SET DEFAULT 'inactive'`);
await queryRunner.query(`DROP TYPE "public"."experiment_state_enum"`);
await queryRunner.query(`ALTER TYPE "public"."experiment_state_enum_old" RENAME TO "experiment_state_enum"`);
await queryRunner.query(
`CREATE TYPE "public"."state_time_log_tostate_enum_old" AS ENUM('cancelled', 'enrolling', 'enrollmentComplete', 'inactive', 'preview', 'scheduled')`
);
await queryRunner.query(
`ALTER TABLE "public"."state_time_log" ALTER COLUMN "toState" TYPE "public"."state_time_log_tostate_enum_old" USING "toState"::"text"::"public"."state_time_log_tostate_enum_old"`
);
await queryRunner.query(`DROP TYPE "public"."state_time_log_tostate_enum"`);
await queryRunner.query(
`ALTER TYPE "public"."state_time_log_tostate_enum_old" RENAME TO "state_time_log_tostate_enum"`
);
await queryRunner.query(
`CREATE TYPE "public"."state_time_log_fromstate_enum_old" AS ENUM('cancelled', 'enrolling', 'enrollmentComplete', 'inactive', 'preview', 'scheduled')`
);
await queryRunner.query(
`ALTER TABLE "public"."state_time_log" ALTER COLUMN "fromState" TYPE "public"."state_time_log_fromstate_enum_old" USING "fromState"::"text"::"public"."state_time_log_fromstate_enum_old"`
);
await queryRunner.query(`DROP TYPE "public"."state_time_log_fromstate_enum"`);
await queryRunner.query(
`ALTER TYPE "public"."state_time_log_fromstate_enum_old" RENAME TO "state_time_log_fromstate_enum"`
);
await queryRunner.query(`DROP TABLE "archived_stats"`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ afterEach(() => {
});

describe('MetricRepository Testing', () => {
it('should delete a metric', async () => {
it('should delete a metric', async () => {
createQueryBuilderStub = sandbox.stub(MetricRepository.prototype, 'createQueryBuilder').returns(deleteQueryBuilder);
const result = {
identifiers: [{ id: metric.key }],
Expand Down Expand Up @@ -106,8 +106,9 @@ describe('MetricRepository Testing', () => {
raw: [metric],
};

selectMock.expects('innerJoin').once().returns(selectQueryBuilder);
selectMock.expects('innerJoin').twice().returns(selectQueryBuilder);
selectMock.expects('where').once().returns(selectQueryBuilder);
selectMock.expects('andWhere').once().returns(selectQueryBuilder);
selectMock.expects('getMany').once().returns(Promise.resolve(result));

const res = await repo.findMetricsWithQueries([metric.key]);
Expand All @@ -121,8 +122,9 @@ describe('MetricRepository Testing', () => {
it('should throw an error when get monitored experiment metric by date range fails', async () => {
createQueryBuilderStub = sandbox.stub(MetricRepository.prototype, 'createQueryBuilder').returns(selectQueryBuilder);

selectMock.expects('innerJoin').once().returns(selectQueryBuilder);
selectMock.expects('innerJoin').twice().returns(selectQueryBuilder);
selectMock.expects('where').once().returns(selectQueryBuilder);
selectMock.expects('andWhere').once().returns(selectQueryBuilder);
selectMock.expects('getMany').once().returns(Promise.reject(err));

expect(async () => {
Expand Down
32 changes: 32 additions & 0 deletions backend/packages/Upgrade/test/unit/services/QueryService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ import { Query } from '../../../src/api/models/Query';
import { Experiment } from '../../../src/api/models/Experiment';
import { ErrorService } from '../../../src/api/services/ErrorService';
import { ErrorRepository } from '../../../src/api/repositories/ErrorRepository';
import { ArchivedStatsRepository } from '../../../src/api/repositories/ArchivedStatsRepository';
import { ArchivedStats } from '../../../src/api/models/ArchivedStats';

const logger = new UpgradeLogger();

describe('Query Service Testing', () => {
let service: QueryService;
let queryRepo: Repository<QueryRepository>;
let archivedStatsRepo: Repository<ArchivedStatsRepository>;
let module: TestingModule;

const exp1 = new Experiment();
Expand Down Expand Up @@ -43,12 +46,19 @@ describe('Query Service Testing', () => {
result: 0,
participantsLogged: 0,
};

const mockArchiveData = new ArchivedStats();
mockArchiveData.id = 'id1';
mockArchiveData.query = mockquery1;
mockArchiveData.result = { mainEffect: logResult, interactionEffect: null };

beforeEach(async () => {
module = await Test.createTestingModule({
providers: [
QueryService,
QueryRepository,
LogRepository,
ArchivedStatsRepository,
ErrorService,
ErrorRepository,
{
Expand All @@ -69,6 +79,12 @@ describe('Query Service Testing', () => {
}),
},
},
{
provide: getRepositoryToken(ArchivedStatsRepository),
useValue: {
find: jest.fn().mockResolvedValue([mockArchiveData]),
},
},
{
provide: ErrorService,
useValue: {
Expand All @@ -80,6 +96,7 @@ describe('Query Service Testing', () => {

service = module.get<QueryService>(QueryService);
queryRepo = module.get<Repository<QueryRepository>>(getRepositoryToken(QueryRepository));
archivedStatsRepo = module.get<Repository<ArchivedStatsRepository>>(getRepositoryToken(ArchivedStatsRepository));
});

it('should be defined', async () => {
Expand All @@ -90,6 +107,10 @@ describe('Query Service Testing', () => {
expect(await queryRepo.find()).toEqual(queryArr);
});

it('should have the repo mocked', async () => {
expect(await archivedStatsRepo.find()).toEqual([mockArchiveData]);
});

it('should find and map queries to experiments', async () => {
const response = await service.find(logger);
expect(response).toEqual([
Expand Down Expand Up @@ -134,4 +155,15 @@ describe('Query Service Testing', () => {
},
]);
});

it('should find and map archivedStats to queries', async () => {
const response = await service.getArchivedStats(['id1'], logger);
expect(response).toEqual([
{
id: mockquery1.id,
interactionEffect: null,
mainEffect: logResult,
},
]);
});
});
Loading

0 comments on commit 58f1e63

Please sign in to comment.