Skip to content

Commit

Permalink
chore: implement created_by_user_id in features (#5994)
Browse files Browse the repository at this point in the history
## About the changes

Adds a scheduled task that every 5 seconds updates 500 entries in the
features table setting `created_by_user_id`.
It does this by looking at the related event, checks created_by and
joins users table for match on username or email, and joins api_tokens
table on username matches. Then picks either a users id if set, or uses
-42 (admin token user)
  • Loading branch information
daveleek committed Jan 25, 2024
1 parent 8ab4aa3 commit c7f13ae
Show file tree
Hide file tree
Showing 8 changed files with 259 additions and 2 deletions.
1 change: 1 addition & 0 deletions src/lib/__snapshots__/create-config.test.ts.snap
Expand Up @@ -79,6 +79,7 @@ exports[`should create default config 1`] = `
"caseInsensitiveInOperators": false,
"celebrateUnleash": false,
"changeRequestConflictHandling": false,
"createdByUserIdDataMigration": true,
"customRootRolesKillSwitch": false,
"demo": false,
"detectSegmentUsageInChangeRequests": false,
Expand Down
Expand Up @@ -328,4 +328,8 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
): Promise<IFeatureTypeCount[]> {
throw new Error('Method not implemented.');
}

setCreatedByUserId(batchSize: number): Promise<void> {
throw new Error('Method not implemented.');
}
}
4 changes: 4 additions & 0 deletions src/lib/features/feature-toggle/feature-toggle-service.ts
Expand Up @@ -2402,6 +2402,10 @@ class FeatureToggleService {
);
}
}

async setFeatureCreatedByUserIdFromEvents(): Promise<void> {
await this.featureToggleStore.setCreatedByUserId(100);
}
}

export default FeatureToggleService;
40 changes: 39 additions & 1 deletion src/lib/features/feature-toggle/feature-toggle-store.ts
Expand Up @@ -18,7 +18,11 @@ import { DEFAULT_ENV } from '../../../lib/util';

import { FeatureToggleListBuilder } from './query-builders/feature-toggle-list-builder';
import { FeatureConfigurationClient } from './types/feature-toggle-strategies-store-type';
import { IFeatureTypeCount, IFlagResolver } from '../../../lib/types';
import {
ADMIN_TOKEN_USER,
IFeatureTypeCount,
IFlagResolver,
} from '../../../lib/types';
import { FeatureToggleRowConverter } from './converters/feature-toggle-row-converter';
import { IFeatureProjectUserParams } from './feature-toggle-controller';

Expand Down Expand Up @@ -718,6 +722,40 @@ export default class FeatureToggleStore implements IFeatureToggleStore {

return result?.potentially_stale ?? false;
}

async setCreatedByUserId(batchSize: number): Promise<void> {
const EVENTS_TABLE = 'events';
const USERS_TABLE = 'users';
const API_TOKEN_TABLE = 'api_tokens';

if (!this.flagResolver.isEnabled('createdByUserIdDataMigration')) {
return;
}
const toUpdate = await this.db(`${TABLE} as f`)
.joinRaw(`JOIN ${EVENTS_TABLE} AS ev ON ev.feature_name = f.name`)
.joinRaw(
`LEFT OUTER JOIN ${USERS_TABLE} AS u on ev.created_by = u.username OR ev.created_by = u.email`,
)
.joinRaw(
`LEFT OUTER JOIN ${API_TOKEN_TABLE} AS t on ev.created_by = t.username`,
)
.whereRaw(
`f.created_by_user_id IS null AND ev.type = 'feature-created'`,
)
.orderBy('f.created_at', 'asc')
.limit(batchSize)
.select(['f.*', 'ev.created_by', 'u.id', 't.username']);

toUpdate
.filter((row) => row.id || row.username)
.forEach(async (row) => {
const id = row.id || ADMIN_TOKEN_USER.id;

await this.db(TABLE)
.update({ created_by_user_id: id })
.where({ name: row.name });
});
}
}

module.exports = FeatureToggleStore;
Expand Up @@ -103,4 +103,6 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
getFeatureTypeCounts(
params: IFeatureProjectUserParams,
): Promise<IFeatureTypeCount[]>;

setCreatedByUserId(batchSize: number): Promise<void>;
}
8 changes: 8 additions & 0 deletions src/lib/features/scheduler/schedule-services.ts
Expand Up @@ -150,4 +150,12 @@ export const scheduleServices = async (
minutesToMilliseconds(3),
'updateAccountLastSeen',
);

schedulerService.schedule(
featureToggleService.setFeatureCreatedByUserIdFromEvents.bind(
featureToggleService,
),
minutesToMilliseconds(15),
'setFeatureCreatedByUserIdFromEvents',
);
};
7 changes: 6 additions & 1 deletion src/lib/types/experimental.ts
Expand Up @@ -46,7 +46,8 @@ export type IFlagKey =
| 'adminTokenKillSwitch'
| 'changeRequestConflictHandling'
| 'executiveDashboard'
| 'feedbackComments';
| 'feedbackComments'
| 'createdByUserIdDataMigration';

export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;

Expand Down Expand Up @@ -222,6 +223,10 @@ const flags: IFlags = {
'',
},
},
createdByUserIdDataMigration: parseEnvVarBoolean(
process.env.CREATED_BY_USERID_DATA_MIGRATION,
true,
),
};

export const defaultExperimentalOptions: IExperimentalOptions = {
Expand Down
195 changes: 195 additions & 0 deletions src/test/e2e/features-created-by-user-id-data-migration.e2e.test.ts
@@ -0,0 +1,195 @@
import { createFeatureToggleService } from '../../lib/features';
import { EventService, FeatureToggleService } from '../../lib/services';
import {
ADMIN_TOKEN_USER,
IUnleashConfig,
IUnleashStores,
} from '../../lib/types';
import { createTestConfig } from '../config/test-config';
import dbInit, { ITestDb } from './helpers/database-init';

let stores: IUnleashStores;
let db: ITestDb;
let service: FeatureToggleService;
let eventService: EventService;
let unleashConfig: IUnleashConfig;

beforeAll(async () => {
const config = createTestConfig();
db = await dbInit(
'features_created_by_user_id_migration',
config.getLogger,
);
unleashConfig = config;
stores = db.stores;

service = createFeatureToggleService(db.rawDatabase, config);

eventService = new EventService(stores, config);
});

afterAll(async () => {
await db.rawDatabase('features').del();
await db.rawDatabase('events').del();
await db.rawDatabase('users').del();
await db.destroy();
});

beforeEach(async () => {
await db.rawDatabase('features').del();
await db.rawDatabase('events').del();
await db.rawDatabase('users').del();
});

test('should set created_by_user_id on features', async () => {
for (let i = 0; i < 100; i++) {
await db.rawDatabase('features').insert({
name: `feature${i}`,
type: 'release',
project: 'default',
description: '--created_by_test--',
});
}

await db.rawDatabase('users').insert({
username: 'test1',
});
await db.rawDatabase('users').insert({
username: 'test2',
});
await db.rawDatabase('users').insert({
username: 'test3',
});
await db.rawDatabase('users').insert({
username: 'test4',
});

for (let i = 0; i < 25; i++) {
await db.rawDatabase('events').insert({
type: 'feature-created',
created_by: 'test1',
feature_name: `feature${i}`,
data: `{"name":"feature${i}","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`,
});
}

for (let i = 25; i < 50; i++) {
await db.rawDatabase('events').insert({
type: 'feature-created',
created_by: 'test2',
feature_name: `feature${i}`,
data: `{"name":"feature${i}","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`,
});
}

for (let i = 50; i < 75; i++) {
await db.rawDatabase('events').insert({
type: 'feature-created',
created_by: 'test3',
feature_name: `feature${i}`,
data: `{"name":"feature${i}","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`,
});
}

for (let i = 75; i < 100; i++) {
await db.rawDatabase('events').insert({
type: 'feature-created',
created_by: 'test4',
feature_name: `feature${i}`,
data: `{"name":"feature${i}","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`,
});
}

await stores.featureToggleStore.setCreatedByUserId(200);

const features = await db.rawDatabase('features').select('*');
const notSet = features.filter(
(f) => !f.created_by_user_id && f.description === '--created_by_test--',
);
const test1 = features.filter((f) => f.created_by_user_id === 1);
const test2 = features.filter((f) => f.created_by_user_id === 2);
const test3 = features.filter((f) => f.created_by_user_id === 3);
const test4 = features.filter((f) => f.created_by_user_id === 4);
expect(notSet).toHaveLength(0);
expect(test1).toHaveLength(25);
expect(test2).toHaveLength(25);
expect(test3).toHaveLength(25);
expect(test4).toHaveLength(25);
});

test('admin tokens get populated to admin token user', async () => {
for (let i = 0; i < 5; i++) {
await db.rawDatabase('features').insert({
name: `feature${i}`,
type: 'release',
project: 'default',
description: '--created_by_test--',
});
}

await db.rawDatabase('users').insert({
username: 'input1',
});

await db.rawDatabase('api_tokens').insert({
secret: 'token1',
username: 'adm-token',
type: 'admin',
environment: 'default',
token_name: 'admin-token',
});

await db.rawDatabase('events').insert({
type: 'feature-created',
created_by: 'input1',
feature_name: 'feature0',
data: `{"name":"feature0","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`,
});

await db.rawDatabase('events').insert({
type: 'feature-created',
created_by: 'input1',
feature_name: 'feature1',
data: `{"name":"feature1","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`,
});

await db.rawDatabase('events').insert({
type: 'feature-created',
created_by: 'adm-token',
feature_name: 'feature2',
data: `{"name":"feature2","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`,
});

await db.rawDatabase('events').insert({
type: 'feature-created',
created_by: 'deleted-user',
feature_name: 'feature3',
data: `{"name":"feature3","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`,
});

await db.rawDatabase('events').insert({
type: 'feature-created',
created_by: 'adm-token',
feature_name: 'feature4',
data: `{"name":"feature4","description":null,"type":"release","project":"default","stale":false,"createdAt":"2024-01-08T10:36:32.866Z","lastSeenAt":null,"impressionData":false,"archivedAt":null,"archived":false}`,
});

await stores.featureToggleStore.setCreatedByUserId(200);

const user = await db
.rawDatabase('users')
.where({ username: 'input1' })
.first('id');

const features = await db.rawDatabase('features').select('*');
const notSet = features.filter(
(f) => !f.created_by_user_id && f.description === '--created_by_test--',
);
const test1 = features.filter((f) => f.created_by_user_id === user.id);
const test2 = features.filter(
(f) => f.created_by_user_id === ADMIN_TOKEN_USER.id,
);
expect(notSet).toHaveLength(1);
expect(test1).toHaveLength(2);
expect(test2).toHaveLength(2);
});

0 comments on commit c7f13ae

Please sign in to comment.