@naturalcycles/abba 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/abba.d.ts CHANGED
@@ -1,88 +1,75 @@
1
1
  import { Saved } from '@naturalcycles/js-lib';
2
- import { AbbaConfig, Bucket, Experiment, ExperimentWithBuckets, UserAssignment } from './types';
2
+ import { AbbaConfig, Bucket, BucketInput, Experiment, ExperimentWithBuckets, GeneratedUserAssignment, UserAssignment } from './types';
3
3
  import { SegmentationData, AssignmentStatistics } from '.';
4
4
  export declare class Abba {
5
+ cfg: AbbaConfig;
6
+ constructor(cfg: AbbaConfig);
5
7
  private experimentDao;
6
8
  private bucketDao;
7
9
  private userAssignmentDao;
8
- constructor({ db }: AbbaConfig);
9
10
  /**
10
- * Returns all experiments
11
+ * Returns all (active and inactive) experiments.
11
12
  *
12
- * @returns
13
+ * Cold method, not cached.
13
14
  */
14
- getAllExperiments(excludeInactive?: boolean): Promise<ExperimentWithBuckets[]>;
15
+ getAllExperiments(): Promise<ExperimentWithBuckets[]>;
15
16
  /**
16
- * Creates a new experiment
17
- *
18
- * @param experiment
19
- * @param buckets
20
- * @returns
17
+ * Returns only active experiments.
18
+ * Hot method.
19
+ * Cached in-memory for N minutes (currently 10).
21
20
  */
22
- createExperiment(experiment: Experiment, buckets: Bucket[]): Promise<ExperimentWithBuckets>;
21
+ getActiveExperiments(): Promise<ExperimentWithBuckets[]>;
23
22
  /**
24
- * Update experiment information, will also validate the buckets ratio if experiment.active is true
25
- *
26
- * @param id
27
- * @param experiment
28
- * @param rules
29
- * @param buckets
30
- * @returns
23
+ * Creates a new experiment.
24
+ * Cold method.
31
25
  */
32
- saveExperiment(id: number, experiment: Experiment, buckets: Bucket[]): Promise<ExperimentWithBuckets>;
26
+ createExperiment(experiment: Experiment, buckets: BucketInput[]): Promise<ExperimentWithBuckets>;
33
27
  /**
34
- * Delete an experiment. Removes all user assignments and buckets
35
- *
36
- * @param id
28
+ * Update experiment information, will also validate the buckets' ratio if experiment.active is true
29
+ * Cold method.
30
+ */
31
+ saveExperiment(experiment: Experiment & {
32
+ id: number;
33
+ }, buckets: Bucket[]): Promise<ExperimentWithBuckets>;
34
+ /**
35
+ * Delete an experiment. Removes all user assignments and buckets.
36
+ * Cold method.
37
37
  */
38
38
  deleteExperiment(id: number): Promise<void>;
39
39
  /**
40
- * Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
40
+ * Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
41
+ * Cold method.
41
42
  *
42
43
  * @param experimentId
43
44
  * @param userId
44
45
  * @param existingOnly Do not generate any new assignments for this experiment
45
46
  * @param segmentationData Required if existingOnly is false
46
- * @returns
47
47
  */
48
- getUserAssignment(experimentId: number, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<Saved<UserAssignment> | null>;
48
+ getUserAssignment(experimentId: number, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<GeneratedUserAssignment | null>;
49
49
  /**
50
- * Get all existing user assignments
51
- *
52
- * @param userId
53
- * @returns
50
+ * Get all existing user assignments.
51
+ * Hot method.
52
+ * Not cached, because Assignments are fast-changing.
54
53
  */
55
54
  getAllExistingUserAssignments(userId: string): Promise<Saved<UserAssignment>[]>;
56
55
  /**
57
- * Generate user assignments for all active experiments. Will return any existing and attempt to generate any new assignments.
58
- *
59
- * @param userId
60
- * @param segmentationData
61
- * @returns
56
+ * Generate user assignments for all active experiments.
57
+ * Will return any existing and attempt to generate any new assignments.
58
+ * Hot method.
62
59
  */
63
- generateUserAssignments(userId: string, segmentationData: SegmentationData): Promise<Saved<UserAssignment>[]>;
60
+ generateUserAssignments(userId: string, segmentationData: SegmentationData): Promise<GeneratedUserAssignment[]>;
64
61
  /**
65
- * Get assignment statistics for an experiment
66
- *
67
- * @param experimentId
68
- * @returns
62
+ * Get assignment statistics for an experiment.
63
+ * Cold method.
69
64
  */
70
65
  getExperimentAssignmentStatistics(experimentId: number): Promise<AssignmentStatistics>;
71
66
  /**
72
- * Generate a new assignment for a given user
73
- *
74
- * @param experimentId
75
- * @param userId
76
- * @param segmentationData
77
- * @returns
67
+ * Generate a new assignment for a given user.
68
+ * Doesn't save it.
78
69
  */
79
- private generateUserAssignment;
70
+ private generateUserAssignmentData;
80
71
  /**
81
72
  * Queries to retrieve an existing user assignment for a given experiment
82
- *
83
- * @param experimentId
84
- * @param userId
85
- * @returns
86
73
  */
87
74
  private getExistingUserAssignment;
88
75
  }
package/dist/abba.js CHANGED
@@ -1,30 +1,30 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Abba = void 0;
4
+ const tslib_1 = require("tslib");
4
5
  const js_lib_1 = require("@naturalcycles/js-lib");
6
+ const nodejs_lib_1 = require("@naturalcycles/nodejs-lib");
5
7
  const types_1 = require("./types");
6
8
  const util_1 = require("./util");
7
9
  const experiment_dao_1 = require("./dao/experiment.dao");
8
10
  const userAssignment_dao_1 = require("./dao/userAssignment.dao");
9
11
  const bucket_dao_1 = require("./dao/bucket.dao");
12
+ const CACHE_TTL = 600000; // 10 minutes
10
13
  class Abba {
11
- constructor({ db }) {
14
+ constructor(cfg) {
15
+ this.cfg = cfg;
16
+ const { db } = cfg;
12
17
  this.experimentDao = (0, experiment_dao_1.experimentDao)(db);
13
18
  this.bucketDao = (0, bucket_dao_1.bucketDao)(db);
14
19
  this.userAssignmentDao = (0, userAssignment_dao_1.userAssignmentDao)(db);
15
20
  }
16
- // TODO: Cache me
17
21
  /**
18
- * Returns all experiments
22
+ * Returns all (active and inactive) experiments.
19
23
  *
20
- * @returns
24
+ * Cold method, not cached.
21
25
  */
22
- async getAllExperiments(excludeInactive = false) {
23
- const query = this.experimentDao.query();
24
- if (excludeInactive) {
25
- query.filter('status', '!=', types_1.AssignmentStatus.Inactive);
26
- }
27
- const experiments = await this.experimentDao.runQuery(query);
26
+ async getAllExperiments() {
27
+ const experiments = await this.experimentDao.query().runQuery();
28
28
  const buckets = await this.bucketDao
29
29
  .query()
30
30
  .filter('experimentId', 'in', experiments.map(e => e.id))
@@ -35,115 +35,139 @@ class Abba {
35
35
  }));
36
36
  }
37
37
  /**
38
- * Creates a new experiment
39
- *
40
- * @param experiment
41
- * @param buckets
42
- * @returns
38
+ * Returns only active experiments.
39
+ * Hot method.
40
+ * Cached in-memory for N minutes (currently 10).
41
+ */
42
+ async getActiveExperiments() {
43
+ const experiments = await this.experimentDao
44
+ .query()
45
+ .filter('status', '!=', types_1.AssignmentStatus.Inactive)
46
+ .runQuery();
47
+ const buckets = await this.bucketDao
48
+ .query()
49
+ .filter('experimentId', 'in', experiments.map(e => e.id))
50
+ .runQuery();
51
+ return experiments.map(experiment => ({
52
+ ...experiment,
53
+ buckets: buckets.filter(bucket => bucket.experimentId === experiment.id),
54
+ }));
55
+ }
56
+ /**
57
+ * Creates a new experiment.
58
+ * Cold method.
43
59
  */
44
60
  async createExperiment(experiment, buckets) {
45
61
  if (experiment.status === types_1.AssignmentStatus.Active) {
46
62
  (0, util_1.validateTotalBucketRatio)(buckets);
47
63
  }
48
- const created = await this.experimentDao.save(experiment);
64
+ await this.experimentDao.save(experiment);
49
65
  return {
50
- ...created,
51
- buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: created.id }))),
66
+ ...experiment,
67
+ buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: experiment.id }))),
52
68
  };
53
69
  }
54
70
  /**
55
- * Update experiment information, will also validate the buckets ratio if experiment.active is true
56
- *
57
- * @param id
58
- * @param experiment
59
- * @param rules
60
- * @param buckets
61
- * @returns
71
+ * Update experiment information, will also validate the buckets' ratio if experiment.active is true
72
+ * Cold method.
62
73
  */
63
- async saveExperiment(id, experiment, buckets) {
74
+ async saveExperiment(experiment, buckets) {
64
75
  if (experiment.status === types_1.AssignmentStatus.Active) {
65
76
  (0, util_1.validateTotalBucketRatio)(buckets);
66
77
  }
67
- const updated = await this.experimentDao.save({
68
- ...experiment,
69
- id,
70
- });
78
+ await this.experimentDao.save(experiment);
71
79
  return {
72
- ...updated,
73
- buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: id }))),
80
+ ...experiment,
81
+ buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: experiment.id }))),
74
82
  };
75
83
  }
76
84
  /**
77
- * Delete an experiment. Removes all user assignments and buckets
78
- *
79
- * @param id
85
+ * Delete an experiment. Removes all user assignments and buckets.
86
+ * Cold method.
80
87
  */
81
88
  async deleteExperiment(id) {
82
89
  await this.experimentDao.deleteById(id);
83
90
  }
84
91
  /**
85
- * Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
92
+ * Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
93
+ * Cold method.
86
94
  *
87
95
  * @param experimentId
88
96
  * @param userId
89
97
  * @param existingOnly Do not generate any new assignments for this experiment
90
98
  * @param segmentationData Required if existingOnly is false
91
- * @returns
92
99
  */
93
100
  async getUserAssignment(experimentId, userId, existingOnly, segmentationData) {
101
+ const existing = await this.getExistingUserAssignment(experimentId, userId);
102
+ if (existing) {
103
+ const experiment = await this.experimentDao.requireById(experimentId);
104
+ const bucket = await this.bucketDao.getById(existing.bucketId || undefined);
105
+ return {
106
+ ...existing,
107
+ experimentName: experiment.name,
108
+ bucketKey: bucket?.key || null,
109
+ };
110
+ }
111
+ if (existingOnly)
112
+ return null;
94
113
  const experiment = await this.experimentDao.requireById(experimentId);
95
- if (!experiment)
96
- throw new Error('Experiment not found');
114
+ if (experiment.status !== types_1.AssignmentStatus.Active)
115
+ return null;
116
+ (0, js_lib_1._assert)(segmentationData, 'Segmentation data required when creating a new assignment');
97
117
  const buckets = await this.bucketDao.getBy('experimentId', experimentId);
98
- const existing = await this.getExistingUserAssignment(experimentId, userId);
99
- if (existing)
100
- return existing;
101
- if (experiment.status !== types_1.AssignmentStatus.Active || existingOnly)
118
+ const assignment = this.generateUserAssignmentData({ ...experiment, buckets }, userId, segmentationData);
119
+ if (!assignment)
102
120
  return null;
103
- if (!segmentationData)
104
- throw new Error('Segmentation data required when creating a new assignment');
105
- return await this.generateUserAssignment({ ...experiment, buckets }, userId, segmentationData);
121
+ const saved = await this.userAssignmentDao.save(assignment);
122
+ return {
123
+ ...saved,
124
+ experimentName: experiment.name,
125
+ bucketKey: buckets.find(b => b.id === saved.bucketId)?.key || null,
126
+ };
106
127
  }
107
128
  /**
108
- * Get all existing user assignments
109
- *
110
- * @param userId
111
- * @returns
129
+ * Get all existing user assignments.
130
+ * Hot method.
131
+ * Not cached, because Assignments are fast-changing.
112
132
  */
113
133
  async getAllExistingUserAssignments(userId) {
114
134
  return await this.userAssignmentDao.getBy('userId', userId);
115
135
  }
116
136
  /**
117
- * Generate user assignments for all active experiments. Will return any existing and attempt to generate any new assignments.
118
- *
119
- * @param userId
120
- * @param segmentationData
121
- * @returns
137
+ * Generate user assignments for all active experiments.
138
+ * Will return any existing and attempt to generate any new assignments.
139
+ * Hot method.
122
140
  */
123
141
  async generateUserAssignments(userId, segmentationData) {
124
- const experiments = await this.getAllExperiments(true);
142
+ const experiments = await this.getActiveExperiments(); // cached
125
143
  const existingAssignments = await this.getAllExistingUserAssignments(userId);
144
+ const newAssignments = [];
145
+ for (const experiment of experiments) {
146
+ const existing = existingAssignments.find(ua => ua.experimentId === experiment.id);
147
+ if (!existing) {
148
+ const assignment = this.generateUserAssignmentData(experiment, userId, segmentationData);
149
+ if (assignment) {
150
+ newAssignments.push(assignment);
151
+ }
152
+ }
153
+ }
154
+ existingAssignments.push(...(await this.userAssignmentDao.saveBatch(newAssignments)));
126
155
  const assignments = [];
127
- const generatedAssignments = [];
128
156
  for (const experiment of experiments) {
129
157
  const existing = existingAssignments.find(ua => ua.experimentId === experiment.id);
130
158
  if (existing) {
131
- assignments.push(existing);
132
- continue;
133
- }
134
- else {
135
- generatedAssignments.push(this.generateUserAssignment(experiment, userId, segmentationData));
159
+ assignments.push({
160
+ ...existing,
161
+ experimentName: experiment.name,
162
+ bucketKey: experiment.buckets.find(i => i.id === existing.bucketId)?.key || null,
163
+ });
136
164
  }
137
165
  }
138
- const generated = await Promise.all(generatedAssignments);
139
- const filtered = generated.filter((ua) => ua !== null);
140
- return [...assignments, ...filtered];
166
+ return assignments;
141
167
  }
142
168
  /**
143
- * Get assignment statistics for an experiment
144
- *
145
- * @param experimentId
146
- * @returns
169
+ * Get assignment statistics for an experiment.
170
+ * Cold method.
147
171
  */
148
172
  async getExperimentAssignmentStatistics(experimentId) {
149
173
  const statistics = {
@@ -155,40 +179,41 @@ class Abba {
155
179
  };
156
180
  const buckets = await this.bucketDao.getBy('experimentId', experimentId);
157
181
  await (0, js_lib_1.pMap)(buckets, async (bucket) => {
158
- const count = this.userAssignmentDao.query().filterEq('bucketId', bucket.id);
159
- statistics[bucket.id] = await this.userAssignmentDao.runQueryCount(count);
182
+ statistics.buckets[bucket.id] = await this.userAssignmentDao
183
+ .query()
184
+ .filterEq('bucketId', bucket.id)
185
+ .runQueryCount();
160
186
  });
161
187
  return statistics;
162
188
  }
163
189
  /**
164
- * Generate a new assignment for a given user
165
- *
166
- * @param experimentId
167
- * @param userId
168
- * @param segmentationData
169
- * @returns
190
+ * Generate a new assignment for a given user.
191
+ * Doesn't save it.
170
192
  */
171
- async generateUserAssignment(experiment, userId, segmentationData) {
193
+ generateUserAssignmentData(experiment, userId, segmentationData) {
172
194
  const segmentationMatch = (0, util_1.validateSegmentationRules)(experiment.rules, segmentationData);
173
195
  if (!segmentationMatch)
174
196
  return null;
175
- return await this.userAssignmentDao.save({
197
+ const bucket = (0, util_1.determineAssignment)(experiment.sampling, experiment.buckets);
198
+ return {
176
199
  userId,
177
200
  experimentId: experiment.id,
178
- bucketId: (0, util_1.determineAssignment)(experiment.sampling, experiment.buckets),
179
- });
201
+ bucketId: bucket?.id || null,
202
+ };
180
203
  }
181
204
  /**
182
205
  * Queries to retrieve an existing user assignment for a given experiment
183
- *
184
- * @param experimentId
185
- * @param userId
186
- * @returns
187
206
  */
188
207
  async getExistingUserAssignment(experimentId, userId) {
189
- const assignments = await this.userAssignmentDao.getBy('userId', userId);
190
- const assignment = assignments.find(assignment => assignment.experimentId === experimentId);
208
+ const [assignment] = await this.userAssignmentDao
209
+ .query()
210
+ .filterEq('userId', userId)
211
+ .filterEq('experimentId', experimentId)
212
+ .runQuery();
191
213
  return assignment || null;
192
214
  }
193
215
  }
216
+ tslib_1.__decorate([
217
+ (0, js_lib_1._AsyncMemo)({ cacheFactory: () => new nodejs_lib_1.LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
218
+ ], Abba.prototype, "getActiveExperiments", null);
194
219
  exports.Abba = Abba;
@@ -13,7 +13,7 @@ const experimentDao = (db) => new ExperimentDao({
13
13
  assignGeneratedIds: true,
14
14
  hooks: {
15
15
  beforeBMToDBM: bm => ({ ...bm, rules: bm.rules.length ? JSON.stringify(bm.rules) : null }),
16
- beforeDBMToBM: dbm => ({ ...dbm, rules: dbm.rules && JSON.parse(dbm.rules) }),
16
+ beforeDBMToBM: dbm => ({ ...dbm, rules: (dbm.rules && JSON.parse(dbm.rules)) || [] }),
17
17
  },
18
18
  });
19
19
  exports.experimentDao = experimentDao;
package/dist/types.d.ts CHANGED
@@ -30,6 +30,10 @@ export declare type UserAssignment = BaseDBEntity<number> & {
30
30
  experimentId: number;
31
31
  bucketId: number | null;
32
32
  };
33
+ export declare type GeneratedUserAssignment = Saved<UserAssignment> & {
34
+ experimentName: string;
35
+ bucketKey: string | null;
36
+ };
33
37
  export declare type SegmentationData = Record<string, string | boolean | number>;
34
38
  export declare enum AssignmentStatus {
35
39
  Active = 1,
package/dist/util.d.ts CHANGED
@@ -2,30 +2,18 @@ import { Saved } from '@naturalcycles/js-lib';
2
2
  import { Bucket, SegmentationData, SegmentationRule } from './types';
3
3
  /**
4
4
  * Generate a random number between 0 and 100
5
- *
6
- * @returns
7
5
  */
8
6
  export declare const rollDie: () => number;
9
7
  /**
10
8
  * Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
11
- *
12
- * @param sampling
13
- * @param buckets
14
- * @returns
15
9
  */
16
- export declare const determineAssignment: (sampling: number, buckets: Saved<Bucket>[]) => number | null;
10
+ export declare const determineAssignment: (sampling: number, buckets: Saved<Bucket>[]) => Bucket | null;
17
11
  /**
18
12
  * Determines which bucket a user assignment will recieve
19
- *
20
- * @param buckets
21
- * @returns
22
13
  */
23
- export declare const determineBucket: (buckets: Saved<Bucket>[]) => number;
14
+ export declare const determineBucket: (buckets: Saved<Bucket>[]) => Bucket;
24
15
  /**
25
16
  * Validate the total ratio of the buckets equals 100
26
- *
27
- * @param buckets
28
- * @returns
29
17
  */
30
18
  export declare const validateTotalBucketRatio: (buckets: Bucket[]) => void;
31
19
  /**
@@ -38,9 +26,5 @@ export declare const validateTotalBucketRatio: (buckets: Bucket[]) => void;
38
26
  export declare const validateSegmentationRules: (rules: SegmentationRule[], segmentationData: SegmentationData) => boolean;
39
27
  /**
40
28
  * Validate a users segmentation data against a single rule
41
- *
42
- * @param rule
43
- * @param segmentationData
44
- * @returns
45
29
  */
46
30
  export declare const validateSegmentationRule: (rule: SegmentationRule, data: SegmentationData) => boolean;
package/dist/util.js CHANGED
@@ -4,8 +4,6 @@ exports.validateSegmentationRule = exports.validateSegmentationRules = exports.v
4
4
  const semver_1 = require("semver");
5
5
  /**
6
6
  * Generate a random number between 0 and 100
7
- *
8
- * @returns
9
7
  */
10
8
  const rollDie = () => {
11
9
  return Math.random() * 100;
@@ -13,10 +11,6 @@ const rollDie = () => {
13
11
  exports.rollDie = rollDie;
14
12
  /**
15
13
  * Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
16
- *
17
- * @param sampling
18
- * @param buckets
19
- * @returns
20
14
  */
21
15
  const determineAssignment = (sampling, buckets) => {
22
16
  // Should this person be considered for the experiment?
@@ -29,9 +23,6 @@ const determineAssignment = (sampling, buckets) => {
29
23
  exports.determineAssignment = determineAssignment;
30
24
  /**
31
25
  * Determines which bucket a user assignment will recieve
32
- *
33
- * @param buckets
34
- * @returns
35
26
  */
36
27
  const determineBucket = (buckets) => {
37
28
  const bucketRoll = (0, exports.rollDie)();
@@ -50,14 +41,11 @@ const determineBucket = (buckets) => {
50
41
  if (!bucket) {
51
42
  throw new Error('Could not detetermine bucket from ratios');
52
43
  }
53
- return bucket.id;
44
+ return bucket;
54
45
  };
55
46
  exports.determineBucket = determineBucket;
56
47
  /**
57
48
  * Validate the total ratio of the buckets equals 100
58
- *
59
- * @param buckets
60
- * @returns
61
49
  */
62
50
  const validateTotalBucketRatio = (buckets) => {
63
51
  const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0);
@@ -83,10 +71,6 @@ const validateSegmentationRules = (rules, segmentationData) => {
83
71
  exports.validateSegmentationRules = validateSegmentationRules;
84
72
  /**
85
73
  * Validate a users segmentation data against a single rule
86
- *
87
- * @param rule
88
- * @param segmentationData
89
- * @returns
90
74
  */
91
75
  const validateSegmentationRule = (rule, data) => {
92
76
  const { key, value, operator } = rule;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naturalcycles/abba",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "scripts": {
5
5
  "prepare": "husky install",
6
6
  "build": "build",
@@ -9,13 +9,14 @@
9
9
  "dependencies": {
10
10
  "@naturalcycles/db-lib": "^8.40.1",
11
11
  "@naturalcycles/js-lib": "^14.98.2",
12
+ "@naturalcycles/nodejs-lib": "^12.70.1",
12
13
  "semver": "^7.3.5"
13
14
  },
14
15
  "devDependencies": {
15
16
  "@naturalcycles/dev-lib": "^12.19.2",
16
- "@types/node": "^16.0.0",
17
+ "@types/node": "^17.0.34",
17
18
  "@types/semver": "^7.3.9",
18
- "jest": "^27.5.1"
19
+ "jest": "^28.1.0"
19
20
  },
20
21
  "files": [
21
22
  "dist",
package/readme.md CHANGED
@@ -154,7 +154,7 @@ async getUserAssignment(
154
154
  userId: string,
155
155
  existingOnly: boolean,
156
156
  segmentationData?: SegmentationData,
157
- ): Promise<Saved<UserAssignment> | null>
157
+ ): Promise<GeneratedUserAssignment | null>
158
158
  ```
159
159
 
160
160
  ### Generate user assignments
@@ -166,7 +166,7 @@ attempt to generate new assignments.
166
166
  async generateUserAssignments(
167
167
  userId: string,
168
168
  segmentationData: SegmentationData,
169
- ): Promise<Saved<UserAssignment>[]>
169
+ ): Promise<GeneratedUserAssignment[]>
170
170
  ```
171
171
 
172
172
  ### Getting assignment statistics
package/src/abba.ts CHANGED
@@ -1,42 +1,43 @@
1
- import { pMap, Saved } from '@naturalcycles/js-lib'
1
+ import { _assert, _AsyncMemo, pMap, Saved } from '@naturalcycles/js-lib'
2
+ import { LRUMemoCache } from '@naturalcycles/nodejs-lib'
2
3
  import {
3
4
  AbbaConfig,
4
5
  AssignmentStatus,
5
6
  Bucket,
7
+ BucketInput,
6
8
  Experiment,
7
9
  ExperimentWithBuckets,
10
+ GeneratedUserAssignment,
8
11
  UserAssignment,
9
12
  } from './types'
10
13
  import { determineAssignment, validateSegmentationRules, validateTotalBucketRatio } from './util'
11
14
  import { ExperimentDao, experimentDao } from './dao/experiment.dao'
12
15
  import { UserAssignmentDao, userAssignmentDao } from './dao/userAssignment.dao'
13
16
  import { BucketDao, bucketDao } from './dao/bucket.dao'
14
- import { SegmentationData, SegmentationRule, AssignmentStatistics } from '.'
17
+ import { SegmentationData, AssignmentStatistics } from '.'
15
18
 
16
- export class Abba {
17
- private experimentDao: ExperimentDao
18
- private bucketDao: BucketDao
19
- private userAssignmentDao: UserAssignmentDao
19
+ const CACHE_TTL = 600_000 // 10 minutes
20
20
 
21
- constructor({ db }: AbbaConfig) {
21
+ export class Abba {
22
+ constructor(public cfg: AbbaConfig) {
23
+ const { db } = cfg
22
24
  this.experimentDao = experimentDao(db)
23
25
  this.bucketDao = bucketDao(db)
24
26
  this.userAssignmentDao = userAssignmentDao(db)
25
27
  }
26
28
 
27
- // TODO: Cache me
29
+ private experimentDao: ExperimentDao
30
+ private bucketDao: BucketDao
31
+ private userAssignmentDao: UserAssignmentDao
32
+
28
33
  /**
29
- * Returns all experiments
34
+ * Returns all (active and inactive) experiments.
30
35
  *
31
- * @returns
36
+ * Cold method, not cached.
32
37
  */
33
- async getAllExperiments(excludeInactive: boolean = false): Promise<ExperimentWithBuckets[]> {
34
- const query = this.experimentDao.query()
35
- if (excludeInactive) {
36
- query.filter('status', '!=', AssignmentStatus.Inactive)
37
- }
38
+ async getAllExperiments(): Promise<ExperimentWithBuckets[]> {
39
+ const experiments = await this.experimentDao.query().runQuery()
38
40
 
39
- const experiments = await this.experimentDao.runQuery(query)
40
41
  const buckets = await this.bucketDao
41
42
  .query()
42
43
  .filter(
@@ -53,149 +54,190 @@ export class Abba {
53
54
  }
54
55
 
55
56
  /**
56
- * Creates a new experiment
57
- *
58
- * @param experiment
59
- * @param buckets
60
- * @returns
57
+ * Returns only active experiments.
58
+ * Hot method.
59
+ * Cached in-memory for N minutes (currently 10).
60
+ */
61
+ @_AsyncMemo({ cacheFactory: () => new LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
62
+ async getActiveExperiments(): Promise<ExperimentWithBuckets[]> {
63
+ const experiments = await this.experimentDao
64
+ .query()
65
+ .filter('status', '!=', AssignmentStatus.Inactive)
66
+ .runQuery()
67
+
68
+ const buckets = await this.bucketDao
69
+ .query()
70
+ .filter(
71
+ 'experimentId',
72
+ 'in',
73
+ experiments.map(e => e.id),
74
+ )
75
+ .runQuery()
76
+
77
+ return experiments.map(experiment => ({
78
+ ...experiment,
79
+ buckets: buckets.filter(bucket => bucket.experimentId === experiment.id),
80
+ }))
81
+ }
82
+
83
+ /**
84
+ * Creates a new experiment.
85
+ * Cold method.
61
86
  */
62
87
  async createExperiment(
63
88
  experiment: Experiment,
64
- buckets: Bucket[],
89
+ buckets: BucketInput[],
65
90
  ): Promise<ExperimentWithBuckets> {
66
91
  if (experiment.status === AssignmentStatus.Active) {
67
92
  validateTotalBucketRatio(buckets)
68
93
  }
69
94
 
70
- const created = await this.experimentDao.save(experiment)
95
+ await this.experimentDao.save(experiment)
71
96
 
72
97
  return {
73
- ...created,
98
+ ...(experiment as Saved<Experiment>),
74
99
  buckets: await this.bucketDao.saveBatch(
75
- buckets.map(b => ({ ...b, experimentId: created.id })),
100
+ buckets.map(b => ({ ...b, experimentId: experiment.id! })),
76
101
  ),
77
102
  }
78
103
  }
79
104
 
80
105
  /**
81
- * Update experiment information, will also validate the buckets ratio if experiment.active is true
82
- *
83
- * @param id
84
- * @param experiment
85
- * @param rules
86
- * @param buckets
87
- * @returns
106
+ * Update experiment information, will also validate the buckets' ratio if experiment.active is true
107
+ * Cold method.
88
108
  */
89
109
  async saveExperiment(
90
- id: number,
91
- experiment: Experiment,
110
+ experiment: Experiment & { id: number },
92
111
  buckets: Bucket[],
93
112
  ): Promise<ExperimentWithBuckets> {
94
113
  if (experiment.status === AssignmentStatus.Active) {
95
114
  validateTotalBucketRatio(buckets)
96
115
  }
97
116
 
98
- const updated = await this.experimentDao.save({
99
- ...experiment,
100
- id,
101
- })
117
+ await this.experimentDao.save(experiment)
102
118
 
103
119
  return {
104
- ...updated,
105
- buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: id }))),
120
+ ...(experiment as Saved<Experiment>),
121
+ buckets: await this.bucketDao.saveBatch(
122
+ buckets.map(b => ({ ...b, experimentId: experiment.id })),
123
+ ),
106
124
  }
107
125
  }
108
126
 
109
127
  /**
110
- * Delete an experiment. Removes all user assignments and buckets
111
- *
112
- * @param id
128
+ * Delete an experiment. Removes all user assignments and buckets.
129
+ * Cold method.
113
130
  */
114
131
  async deleteExperiment(id: number): Promise<void> {
115
132
  await this.experimentDao.deleteById(id)
116
133
  }
117
134
 
118
135
  /**
119
- * Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
136
+ * Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
137
+ * Cold method.
120
138
  *
121
139
  * @param experimentId
122
140
  * @param userId
123
141
  * @param existingOnly Do not generate any new assignments for this experiment
124
142
  * @param segmentationData Required if existingOnly is false
125
- * @returns
126
143
  */
127
144
  async getUserAssignment(
128
145
  experimentId: number,
129
146
  userId: string,
130
147
  existingOnly: boolean,
131
148
  segmentationData?: SegmentationData,
132
- ): Promise<Saved<UserAssignment> | null> {
149
+ ): Promise<GeneratedUserAssignment | null> {
150
+ const existing = await this.getExistingUserAssignment(experimentId, userId)
151
+
152
+ if (existing) {
153
+ const experiment = await this.experimentDao.requireById(experimentId)
154
+ const bucket = await this.bucketDao.getById(existing.bucketId || undefined)
155
+ return {
156
+ ...existing,
157
+ experimentName: experiment.name,
158
+ bucketKey: bucket?.key || null,
159
+ }
160
+ }
161
+
162
+ if (existingOnly) return null
163
+
133
164
  const experiment = await this.experimentDao.requireById(experimentId)
134
- if (!experiment) throw new Error('Experiment not found')
165
+ if (experiment.status !== AssignmentStatus.Active) return null
135
166
 
136
- const buckets = await this.bucketDao.getBy('experimentId', experimentId)
167
+ _assert(segmentationData, 'Segmentation data required when creating a new assignment')
137
168
 
138
- const existing = await this.getExistingUserAssignment(experimentId, userId)
139
- if (existing) return existing
169
+ const buckets = await this.bucketDao.getBy('experimentId', experimentId)
140
170
 
141
- if (experiment.status !== AssignmentStatus.Active || existingOnly) return null
171
+ const assignment = this.generateUserAssignmentData(
172
+ { ...experiment, buckets },
173
+ userId,
174
+ segmentationData,
175
+ )
176
+ if (!assignment) return null
142
177
 
143
- if (!segmentationData)
144
- throw new Error('Segmentation data required when creating a new assignment')
178
+ const saved = await this.userAssignmentDao.save(assignment)
145
179
 
146
- return await this.generateUserAssignment({ ...experiment, buckets }, userId, segmentationData)
180
+ return {
181
+ ...saved,
182
+ experimentName: experiment.name,
183
+ bucketKey: buckets.find(b => b.id === saved.bucketId)?.key || null,
184
+ }
147
185
  }
148
186
 
149
187
  /**
150
- * Get all existing user assignments
151
- *
152
- * @param userId
153
- * @returns
188
+ * Get all existing user assignments.
189
+ * Hot method.
190
+ * Not cached, because Assignments are fast-changing.
154
191
  */
155
192
  async getAllExistingUserAssignments(userId: string): Promise<Saved<UserAssignment>[]> {
156
193
  return await this.userAssignmentDao.getBy('userId', userId)
157
194
  }
158
195
 
159
196
  /**
160
- * Generate user assignments for all active experiments. Will return any existing and attempt to generate any new assignments.
161
- *
162
- * @param userId
163
- * @param segmentationData
164
- * @returns
197
+ * Generate user assignments for all active experiments.
198
+ * Will return any existing and attempt to generate any new assignments.
199
+ * Hot method.
165
200
  */
166
201
  async generateUserAssignments(
167
202
  userId: string,
168
203
  segmentationData: SegmentationData,
169
- ): Promise<Saved<UserAssignment>[]> {
170
- const experiments = await this.getAllExperiments(true)
204
+ ): Promise<GeneratedUserAssignment[]> {
205
+ const experiments = await this.getActiveExperiments() // cached
171
206
  const existingAssignments = await this.getAllExistingUserAssignments(userId)
172
207
 
173
- const assignments: Saved<UserAssignment>[] = []
174
- const generatedAssignments: Promise<Saved<UserAssignment> | null>[] = []
208
+ const newAssignments: UserAssignment[] = []
209
+ for (const experiment of experiments) {
210
+ const existing = existingAssignments.find(ua => ua.experimentId === experiment.id)
211
+ if (!existing) {
212
+ const assignment = this.generateUserAssignmentData(experiment, userId, segmentationData)
213
+ if (assignment) {
214
+ newAssignments.push(assignment)
215
+ }
216
+ }
217
+ }
218
+
219
+ existingAssignments.push(...(await this.userAssignmentDao.saveBatch(newAssignments)))
175
220
 
221
+ const assignments: GeneratedUserAssignment[] = []
176
222
  for (const experiment of experiments) {
177
223
  const existing = existingAssignments.find(ua => ua.experimentId === experiment.id)
178
224
  if (existing) {
179
- assignments.push(existing)
180
- continue
181
- } else {
182
- generatedAssignments.push(this.generateUserAssignment(experiment, userId, segmentationData))
225
+ assignments.push({
226
+ ...existing,
227
+ experimentName: experiment.name,
228
+ bucketKey: experiment.buckets.find(i => i.id === existing.bucketId)?.key || null,
229
+ })
183
230
  }
184
231
  }
185
-
186
- const generated = await Promise.all(generatedAssignments)
187
- const filtered = generated.filter((ua): ua is Saved<UserAssignment> => ua !== null)
188
- return [...assignments, ...filtered]
232
+ return assignments
189
233
  }
190
234
 
191
235
  /**
192
- * Get assignment statistics for an experiment
193
- *
194
- * @param experimentId
195
- * @returns
236
+ * Get assignment statistics for an experiment.
237
+ * Cold method.
196
238
  */
197
239
  async getExperimentAssignmentStatistics(experimentId: number): Promise<AssignmentStatistics> {
198
- const statistics = {
240
+ const statistics: AssignmentStatistics = {
199
241
  sampled: await this.userAssignmentDao
200
242
  .query()
201
243
  .filterEq('experimentId', experimentId)
@@ -205,52 +247,49 @@ export class Abba {
205
247
 
206
248
  const buckets = await this.bucketDao.getBy('experimentId', experimentId)
207
249
  await pMap(buckets, async bucket => {
208
- const count = this.userAssignmentDao.query().filterEq('bucketId', bucket.id)
209
- statistics[bucket.id] = await this.userAssignmentDao.runQueryCount(count)
250
+ statistics.buckets[bucket.id] = await this.userAssignmentDao
251
+ .query()
252
+ .filterEq('bucketId', bucket.id)
253
+ .runQueryCount()
210
254
  })
211
255
 
212
256
  return statistics
213
257
  }
214
258
 
215
259
  /**
216
- * Generate a new assignment for a given user
217
- *
218
- * @param experimentId
219
- * @param userId
220
- * @param segmentationData
221
- * @returns
260
+ * Generate a new assignment for a given user.
261
+ * Doesn't save it.
222
262
  */
223
- private async generateUserAssignment(
263
+ private generateUserAssignmentData(
224
264
  experiment: ExperimentWithBuckets,
225
265
  userId: string,
226
266
  segmentationData: SegmentationData,
227
- ): Promise<Saved<UserAssignment> | null> {
228
- const segmentationMatch = validateSegmentationRules(
229
- experiment.rules as unknown as SegmentationRule[],
230
- segmentationData,
231
- )
267
+ ): UserAssignment | null {
268
+ const segmentationMatch = validateSegmentationRules(experiment.rules, segmentationData)
232
269
  if (!segmentationMatch) return null
233
270
 
234
- return await this.userAssignmentDao.save({
271
+ const bucket = determineAssignment(experiment.sampling, experiment.buckets)
272
+
273
+ return {
235
274
  userId,
236
275
  experimentId: experiment.id,
237
- bucketId: determineAssignment(experiment.sampling, experiment.buckets),
238
- })
276
+ bucketId: bucket?.id || null,
277
+ }
239
278
  }
240
279
 
241
280
  /**
242
281
  * Queries to retrieve an existing user assignment for a given experiment
243
- *
244
- * @param experimentId
245
- * @param userId
246
- * @returns
247
282
  */
248
283
  private async getExistingUserAssignment(
249
284
  experimentId: number,
250
285
  userId: string,
251
286
  ): Promise<Saved<UserAssignment> | null> {
252
- const assignments = await this.userAssignmentDao.getBy('userId', userId)
253
- const assignment = assignments.find(assignment => assignment.experimentId === experimentId)
287
+ const [assignment] = await this.userAssignmentDao
288
+ .query()
289
+ .filterEq('userId', userId)
290
+ .filterEq('experimentId', experimentId)
291
+ .runQuery()
292
+
254
293
  return assignment || null
255
294
  }
256
295
  }
@@ -17,6 +17,6 @@ export const experimentDao = (db: CommonDB): ExperimentDao =>
17
17
  assignGeneratedIds: true,
18
18
  hooks: {
19
19
  beforeBMToDBM: bm => ({ ...bm, rules: bm.rules.length ? JSON.stringify(bm.rules) : null }),
20
- beforeDBMToBM: dbm => ({ ...dbm, rules: dbm.rules && JSON.parse(dbm.rules) }),
20
+ beforeDBMToBM: dbm => ({ ...dbm, rules: (dbm.rules && JSON.parse(dbm.rules)) || [] }),
21
21
  },
22
22
  })
package/src/types.ts CHANGED
@@ -3,6 +3,14 @@ import { BaseDBEntity, Saved } from '@naturalcycles/js-lib'
3
3
 
4
4
  export interface AbbaConfig {
5
5
  db: CommonDB
6
+
7
+ /**
8
+ * Determines for how long to cache "hot" requests.
9
+ * Caches in memory.
10
+ * Default is 10.
11
+ * Set to 0 to disable cache (every request will hit the DB).
12
+ */
13
+ // cacheMinutes?: number
6
14
  }
7
15
 
8
16
  export type BaseExperiment = BaseDBEntity<number> & {
@@ -38,6 +46,11 @@ export type UserAssignment = BaseDBEntity<number> & {
38
46
  bucketId: number | null
39
47
  }
40
48
 
49
+ export type GeneratedUserAssignment = Saved<UserAssignment> & {
50
+ experimentName: string
51
+ bucketKey: string | null
52
+ }
53
+
41
54
  export type SegmentationData = Record<string, string | boolean | number>
42
55
 
43
56
  export enum AssignmentStatus {
package/src/util.ts CHANGED
@@ -4,8 +4,6 @@ import { Bucket, SegmentationData, SegmentationRule } from './types'
4
4
 
5
5
  /**
6
6
  * Generate a random number between 0 and 100
7
- *
8
- * @returns
9
7
  */
10
8
  export const rollDie = (): number => {
11
9
  return Math.random() * 100
@@ -13,12 +11,8 @@ export const rollDie = (): number => {
13
11
 
14
12
  /**
15
13
  * Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
16
- *
17
- * @param sampling
18
- * @param buckets
19
- * @returns
20
14
  */
21
- export const determineAssignment = (sampling: number, buckets: Saved<Bucket>[]): number | null => {
15
+ export const determineAssignment = (sampling: number, buckets: Saved<Bucket>[]): Bucket | null => {
22
16
  // Should this person be considered for the experiment?
23
17
  if (rollDie() > sampling) {
24
18
  return null
@@ -30,11 +24,8 @@ export const determineAssignment = (sampling: number, buckets: Saved<Bucket>[]):
30
24
 
31
25
  /**
32
26
  * Determines which bucket a user assignment will recieve
33
- *
34
- * @param buckets
35
- * @returns
36
27
  */
37
- export const determineBucket = (buckets: Saved<Bucket>[]): number => {
28
+ export const determineBucket = (buckets: Saved<Bucket>[]): Bucket => {
38
29
  const bucketRoll = rollDie()
39
30
  let range: [number, number] | undefined
40
31
  const bucket = buckets.find(b => {
@@ -53,14 +44,11 @@ export const determineBucket = (buckets: Saved<Bucket>[]): number => {
53
44
  throw new Error('Could not detetermine bucket from ratios')
54
45
  }
55
46
 
56
- return bucket.id
47
+ return bucket
57
48
  }
58
49
 
59
50
  /**
60
51
  * Validate the total ratio of the buckets equals 100
61
- *
62
- * @param buckets
63
- * @returns
64
52
  */
65
53
  export const validateTotalBucketRatio = (buckets: Bucket[]): void => {
66
54
  const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0)
@@ -88,10 +76,6 @@ export const validateSegmentationRules = (
88
76
 
89
77
  /**
90
78
  * Validate a users segmentation data against a single rule
91
- *
92
- * @param rule
93
- * @param segmentationData
94
- * @returns
95
79
  */
96
80
  export const validateSegmentationRule = (
97
81
  rule: SegmentationRule,