@naturalcycles/abba 1.13.0 → 1.14.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
@@ -3,22 +3,19 @@ import { AbbaConfig, Bucket, BucketInput, Experiment, ExperimentWithBuckets, Gen
3
3
  import { SegmentationData, AssignmentStatistics } from '.';
4
4
  export declare class Abba {
5
5
  cfg: AbbaConfig;
6
- constructor(cfg: AbbaConfig);
7
6
  private experimentDao;
8
7
  private bucketDao;
9
8
  private userAssignmentDao;
9
+ constructor(cfg: AbbaConfig);
10
10
  /**
11
- * Returns all (active and inactive) experiments.
12
- *
13
- * Cold method, not cached.
11
+ * Returns all experiments.
12
+ * Cached (see CACHE_TTL)
14
13
  */
15
14
  getAllExperiments(): Promise<ExperimentWithBuckets[]>;
16
15
  /**
17
- * Returns only active experiments.
18
- * Hot method.
19
- * Cached in-memory for N minutes (currently 10).
16
+ * Returns all experiments.
20
17
  */
21
- getActiveExperiments(): Promise<ExperimentWithBuckets[]>;
18
+ getAllExperimentsNoCache(): Promise<ExperimentWithBuckets[]>;
22
19
  /**
23
20
  * Creates a new experiment.
24
21
  * Cold method.
@@ -31,11 +28,15 @@ export declare class Abba {
31
28
  saveExperiment(experiment: Experiment & {
32
29
  id: number;
33
30
  }, buckets: Bucket[]): Promise<ExperimentWithBuckets>;
31
+ /**
32
+ * Ensures that mutual exclusions are maintained
33
+ */
34
+ private updateExclusions;
34
35
  /**
35
36
  * Delete an experiment. Removes all user assignments and buckets.
36
37
  * Cold method.
37
38
  */
38
- deleteExperiment(id: number): Promise<void>;
39
+ deleteExperiment(experimentId: number): Promise<void>;
39
40
  /**
40
41
  * Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
41
42
  * Cold method.
@@ -63,13 +64,4 @@ export declare class Abba {
63
64
  * Cold method.
64
65
  */
65
66
  getExperimentAssignmentStatistics(experimentId: number): Promise<AssignmentStatistics>;
66
- /**
67
- * Generate a new assignment for a given user.
68
- * Doesn't save it.
69
- */
70
- private generateUserAssignmentData;
71
- /**
72
- * Queries to retrieve an existing user assignment for a given experiment
73
- */
74
- private getExistingUserAssignment;
75
67
  }
package/dist/abba.js CHANGED
@@ -9,45 +9,30 @@ const util_1 = require("./util");
9
9
  const experiment_dao_1 = require("./dao/experiment.dao");
10
10
  const userAssignment_dao_1 = require("./dao/userAssignment.dao");
11
11
  const bucket_dao_1 = require("./dao/bucket.dao");
12
+ /**
13
+ * 10 minutes
14
+ */
12
15
  const CACHE_TTL = 600000; // 10 minutes
13
16
  class Abba {
14
17
  constructor(cfg) {
15
18
  this.cfg = cfg;
16
- const { db } = cfg;
17
- this.experimentDao = (0, experiment_dao_1.experimentDao)(db);
18
- this.bucketDao = (0, bucket_dao_1.bucketDao)(db);
19
- this.userAssignmentDao = (0, userAssignment_dao_1.userAssignmentDao)(db);
19
+ this.experimentDao = (0, experiment_dao_1.experimentDao)(this.cfg.db);
20
+ this.bucketDao = (0, bucket_dao_1.bucketDao)(this.cfg.db);
21
+ this.userAssignmentDao = (0, userAssignment_dao_1.userAssignmentDao)(this.cfg.db);
20
22
  }
21
23
  /**
22
- * Returns all (active and inactive) experiments.
23
- *
24
- * Cold method, not cached.
24
+ * Returns all experiments.
25
+ * Cached (see CACHE_TTL)
25
26
  */
26
27
  async getAllExperiments() {
27
- const experiments = await this.experimentDao.query().runQuery();
28
- const buckets = await this.bucketDao
29
- .query()
30
- .filter('experimentId', 'in', experiments.map(e => e.id))
31
- .runQuery();
32
- return experiments.map(experiment => ({
33
- ...experiment,
34
- buckets: buckets.filter(bucket => bucket.experimentId === experiment.id),
35
- }));
28
+ return await this.getAllExperimentsNoCache();
36
29
  }
37
30
  /**
38
- * Returns only active experiments.
39
- * Hot method.
40
- * Cached in-memory for N minutes (currently 10).
31
+ * Returns all experiments.
41
32
  */
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();
33
+ async getAllExperimentsNoCache() {
34
+ const experiments = await this.experimentDao.getAll();
35
+ const buckets = await this.bucketDao.getAll();
51
36
  return experiments.map(experiment => ({
52
37
  ...experiment,
53
38
  buckets: buckets.filter(bucket => bucket.experimentId === experiment.id),
@@ -61,10 +46,12 @@ class Abba {
61
46
  if (experiment.status === types_1.AssignmentStatus.Active) {
62
47
  (0, util_1.validateTotalBucketRatio)(buckets);
63
48
  }
64
- await this.experimentDao.save(experiment);
49
+ const created = await this.experimentDao.save(experiment);
50
+ const createdbuckets = await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: experiment.id })));
51
+ await this.updateExclusions(created.id, created.exclusions);
65
52
  return {
66
- ...experiment,
67
- buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: experiment.id }))),
53
+ ...created,
54
+ buckets: createdbuckets,
68
55
  };
69
56
  }
70
57
  /**
@@ -75,18 +62,43 @@ class Abba {
75
62
  if (experiment.status === types_1.AssignmentStatus.Active) {
76
63
  (0, util_1.validateTotalBucketRatio)(buckets);
77
64
  }
78
- await this.experimentDao.save(experiment);
65
+ const updatedExperiment = await this.experimentDao.save(experiment);
66
+ const updatedBuckets = await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: experiment.id })));
67
+ await this.updateExclusions(updatedExperiment.id, updatedExperiment.exclusions);
79
68
  return {
80
- ...experiment,
81
- buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: experiment.id }))),
69
+ ...updatedExperiment,
70
+ buckets: updatedBuckets,
82
71
  };
83
72
  }
73
+ /**
74
+ * Ensures that mutual exclusions are maintained
75
+ */
76
+ async updateExclusions(experimentId, updatedExclusions) {
77
+ const experiments = await this.experimentDao.getAll();
78
+ const requiresUpdating = [];
79
+ experiments.map(experiment => {
80
+ // Make sure it's mutual
81
+ if (updatedExclusions.includes(experiment.id) &&
82
+ !experiment.exclusions.includes(experimentId)) {
83
+ experiment.exclusions.push(experimentId);
84
+ requiresUpdating.push(experiment);
85
+ }
86
+ // Make sure it's mutual
87
+ if (!updatedExclusions.includes(experiment.id) &&
88
+ experiment.exclusions.includes(experimentId)) {
89
+ experiment.exclusions = experiment.exclusions.filter(id => id !== experimentId);
90
+ requiresUpdating.push(experiment);
91
+ }
92
+ });
93
+ await this.experimentDao.saveBatch(requiresUpdating);
94
+ }
84
95
  /**
85
96
  * Delete an experiment. Removes all user assignments and buckets.
86
97
  * Cold method.
87
98
  */
88
- async deleteExperiment(id) {
89
- await this.experimentDao.deleteById(id);
99
+ async deleteExperiment(experimentId) {
100
+ await this.experimentDao.deleteById(experimentId);
101
+ await this.updateExclusions(experimentId, []);
90
102
  }
91
103
  /**
92
104
  * Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
@@ -98,7 +110,8 @@ class Abba {
98
110
  * @param segmentationData Required if existingOnly is false
99
111
  */
100
112
  async getUserAssignment(experimentId, userId, existingOnly, segmentationData) {
101
- const existing = await this.getExistingUserAssignment(experimentId, userId);
113
+ const existingAssignments = await this.getAllExistingUserAssignments(userId);
114
+ const existing = existingAssignments.find(a => a.experimentId === experimentId);
102
115
  if (existing) {
103
116
  let bucketKey = null;
104
117
  if (existing.bucketId) {
@@ -109,18 +122,20 @@ class Abba {
109
122
  }
110
123
  if (existingOnly)
111
124
  return null;
112
- const experiment = await this.experimentDao.requireById(experimentId);
113
- if (!(0, util_1.canGenerateNewAssignments)(experiment))
125
+ const experiments = await this.getAllExperiments();
126
+ const experiment = experiments.find(e => e.id === experimentId);
127
+ (0, js_lib_1._assert)(experiment, `Experiment does not exist: ${experimentId}`);
128
+ const exclusionSet = (0, util_1.getUserExclusionSet)(experiments, existingAssignments);
129
+ if (!(0, util_1.canGenerateNewAssignments)(experiment, exclusionSet))
114
130
  return null;
115
131
  (0, js_lib_1._assert)(segmentationData, 'Segmentation data required when creating a new assignment');
116
- const buckets = await this.bucketDao.getBy('experimentId', experimentId);
117
- const assignment = this.generateUserAssignmentData({ ...experiment, buckets }, userId, segmentationData);
132
+ const assignment = (0, util_1.generateUserAssignmentData)(experiment, userId, segmentationData);
118
133
  if (!assignment)
119
134
  return null;
120
135
  const saved = await this.userAssignmentDao.save(assignment);
121
136
  return {
122
137
  ...saved,
123
- bucketKey: buckets.find(b => b.id === saved.bucketId)?.key || null,
138
+ bucketKey: experiment.buckets.find(b => b.id === saved.bucketId)?.key || null,
124
139
  };
125
140
  }
126
141
  /**
@@ -137,11 +152,16 @@ class Abba {
137
152
  * Hot method.
138
153
  */
139
154
  async generateUserAssignments(userId, segmentationData, existingOnly = false) {
140
- const experiments = await this.getActiveExperiments(); // cached
155
+ const experiments = await this.getAllExperiments();
156
+ const existingAssignments = await this.getAllExistingUserAssignments(userId);
157
+ const exclusionSet = (0, util_1.getUserExclusionSet)(experiments, existingAssignments);
141
158
  const assignments = [];
142
159
  const newAssignments = [];
143
- const existingAssignments = await this.getAllExistingUserAssignments(userId);
144
- for (const experiment of experiments) {
160
+ // Shuffling means that randomisation occurs in the mutual exclusion
161
+ // as experiments are looped through sequentially, this removes the risk of the same experiment always being assigned first in the list of mutually exclusive experiments
162
+ // This is simmpler than trying to resolve after assignments have already been determined
163
+ const availableExperiments = (0, js_lib_1._shuffle)(experiments.filter(e => e.status === types_1.AssignmentStatus.Active || e.status === types_1.AssignmentStatus.Paused));
164
+ for (const experiment of availableExperiments) {
145
165
  const existing = existingAssignments.find(ua => ua.experimentId === experiment.id);
146
166
  if (existing) {
147
167
  assignments.push({
@@ -149,8 +169,8 @@ class Abba {
149
169
  bucketKey: experiment.buckets.find(b => b.id === existing.bucketId)?.key || null,
150
170
  });
151
171
  }
152
- else if (!existingOnly && (0, util_1.canGenerateNewAssignments)(experiment)) {
153
- const assignment = this.generateUserAssignmentData(experiment, userId, segmentationData);
172
+ else if (!existingOnly && (0, util_1.canGenerateNewAssignments)(experiment, exclusionSet)) {
173
+ const assignment = (0, util_1.generateUserAssignmentData)(experiment, userId, segmentationData);
154
174
  if (assignment) {
155
175
  const created = this.userAssignmentDao.create(assignment);
156
176
  newAssignments.push(created);
@@ -158,6 +178,8 @@ class Abba {
158
178
  ...created,
159
179
  bucketKey: experiment.buckets.find(b => b.id === created.bucketId)?.key || null,
160
180
  });
181
+ // Prevent future exclusion clashes
182
+ experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
161
183
  }
162
184
  }
163
185
  }
@@ -185,34 +207,8 @@ class Abba {
185
207
  });
186
208
  return statistics;
187
209
  }
188
- /**
189
- * Generate a new assignment for a given user.
190
- * Doesn't save it.
191
- */
192
- generateUserAssignmentData(experiment, userId, segmentationData) {
193
- const segmentationMatch = (0, util_1.validateSegmentationRules)(experiment.rules, segmentationData);
194
- if (!segmentationMatch)
195
- return null;
196
- const bucket = (0, util_1.determineAssignment)(experiment.sampling, experiment.buckets);
197
- return {
198
- userId,
199
- experimentId: experiment.id,
200
- bucketId: bucket?.id || null,
201
- };
202
- }
203
- /**
204
- * Queries to retrieve an existing user assignment for a given experiment
205
- */
206
- async getExistingUserAssignment(experimentId, userId) {
207
- const [assignment] = await this.userAssignmentDao
208
- .query()
209
- .filterEq('userId', userId)
210
- .filterEq('experimentId', experimentId)
211
- .runQuery();
212
- return assignment || null;
213
- }
214
210
  }
215
211
  tslib_1.__decorate([
216
212
  (0, js_lib_1._AsyncMemo)({ cacheFactory: () => new nodejs_lib_1.LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
217
- ], Abba.prototype, "getActiveExperiments", null);
213
+ ], Abba.prototype, "getAllExperiments", null);
218
214
  exports.Abba = Abba;
@@ -3,6 +3,7 @@ import { Saved } from '@naturalcycles/js-lib';
3
3
  import { BaseExperiment, Experiment } from '../types';
4
4
  type ExperimentDBM = Saved<BaseExperiment> & {
5
5
  rules: string | null;
6
+ exclusions: string | null;
6
7
  };
7
8
  export declare class ExperimentDao extends CommonDao<Experiment, ExperimentDBM> {
8
9
  }
@@ -12,8 +12,16 @@ const experimentDao = (db) => new ExperimentDao({
12
12
  idType: 'number',
13
13
  assignGeneratedIds: true,
14
14
  hooks: {
15
- beforeBMToDBM: bm => ({ ...bm, rules: bm.rules.length ? JSON.stringify(bm.rules) : null }),
16
- beforeDBMToBM: dbm => ({ ...dbm, rules: (dbm.rules && JSON.parse(dbm.rules)) || [] }),
15
+ beforeBMToDBM: bm => ({
16
+ ...bm,
17
+ rules: bm.rules.length ? JSON.stringify(bm.rules) : null,
18
+ exclusions: bm.exclusions.length ? JSON.stringify(bm.exclusions) : null,
19
+ }),
20
+ beforeDBMToBM: dbm => ({
21
+ ...dbm,
22
+ rules: (dbm.rules && JSON.parse(dbm.rules)) || [],
23
+ exclusions: (dbm.exclusions && JSON.parse(dbm.exclusions)) || [],
24
+ }),
17
25
  },
18
26
  });
19
27
  exports.experimentDao = experimentDao;
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS `Experiment` (
21
21
  `created` INTEGER(11) NOT NULL,
22
22
  `updated` INTEGER(11) NOT NULL,
23
23
  `rules` JSON NULL,
24
+ `exclusions` JSON NULL,
24
25
 
25
26
  PRIMARY KEY (`id`)
26
27
  ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CommonDB } from '@naturalcycles/db-lib';
2
- import { BaseDBEntity, IsoDateString, Saved } from '@naturalcycles/js-lib';
2
+ import { AnyObject, BaseDBEntity, Saved } from '@naturalcycles/js-lib';
3
3
  export interface AbbaConfig {
4
4
  db: CommonDB;
5
5
  }
@@ -8,11 +8,12 @@ export type BaseExperiment = BaseDBEntity<number> & {
8
8
  status: number;
9
9
  sampling: number;
10
10
  description: string | null;
11
- startDateIncl: IsoDateString;
12
- endDateExcl: IsoDateString;
11
+ startDateIncl: Date;
12
+ endDateExcl: Date;
13
13
  };
14
14
  export type Experiment = BaseExperiment & {
15
15
  rules: SegmentationRule[];
16
+ exclusions: number[];
16
17
  };
17
18
  export type ExperimentWithBuckets = Saved<Experiment> & {
18
19
  buckets: Saved<Bucket>[];
@@ -22,11 +23,7 @@ export interface BucketInput {
22
23
  key: string;
23
24
  ratio: number;
24
25
  }
25
- export type Bucket = BaseDBEntity<number> & {
26
- experimentId: number;
27
- key: string;
28
- ratio: number;
29
- };
26
+ export type Bucket = BaseDBEntity<number> & BucketInput;
30
27
  export type UserAssignment = BaseDBEntity<number> & {
31
28
  userId: string;
32
29
  experimentId: number;
@@ -35,7 +32,7 @@ export type UserAssignment = BaseDBEntity<number> & {
35
32
  export type GeneratedUserAssignment = Saved<UserAssignment> & {
36
33
  bucketKey: string | null;
37
34
  };
38
- export type SegmentationData = Record<string, string | boolean | number>;
35
+ export type SegmentationData = AnyObject;
39
36
  export declare enum AssignmentStatus {
40
37
  /**
41
38
  * Will return existing assignments and generate new assignments
@@ -61,3 +58,4 @@ export interface AssignmentStatistics {
61
58
  [id: string]: number;
62
59
  };
63
60
  }
61
+ export type ExclusionSet = Set<number>;
package/dist/util.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { Saved } from '@naturalcycles/js-lib';
2
- import { Bucket, Experiment, SegmentationData, SegmentationRule } from './types';
2
+ import { Bucket, ExclusionSet, Experiment, ExperimentWithBuckets, SegmentationData, SegmentationRule, UserAssignment } from './types';
3
+ /**
4
+ * Generate a new assignment for a given user.
5
+ * Doesn't save it.
6
+ */
7
+ export declare const generateUserAssignmentData: (experiment: ExperimentWithBuckets, userId: string, segmentationData: SegmentationData) => UserAssignment | null;
3
8
  /**
4
9
  * Generate a random number between 0 and 100
5
10
  */
@@ -31,4 +36,9 @@ export declare const validateSegmentationRule: (rule: SegmentationRule, data: Se
31
36
  /**
32
37
  * Returns true if an experiment is able to generate new assignments based on status and start/end dates
33
38
  */
34
- export declare const canGenerateNewAssignments: (experiment: Experiment) => boolean;
39
+ export declare const canGenerateNewAssignments: (experiment: Experiment, exclusionSet: ExclusionSet) => boolean;
40
+ /**
41
+ * Returns an object that includes keys of all experimentIds a user should not be assigned to
42
+ * based on a combination of existing assignments and mutual exclusion configuration
43
+ */
44
+ export declare const getUserExclusionSet: (experiments: Experiment[], existingAssignments: UserAssignment[]) => ExclusionSet;
package/dist/util.js CHANGED
@@ -1,9 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.canGenerateNewAssignments = exports.validateSegmentationRule = exports.validateSegmentationRules = exports.validateTotalBucketRatio = exports.determineBucket = exports.determineAssignment = exports.rollDie = void 0;
3
+ exports.getUserExclusionSet = exports.canGenerateNewAssignments = exports.validateSegmentationRule = exports.validateSegmentationRules = exports.validateTotalBucketRatio = exports.determineBucket = exports.determineAssignment = exports.rollDie = exports.generateUserAssignmentData = void 0;
4
4
  const js_lib_1 = require("@naturalcycles/js-lib");
5
5
  const semver_1 = require("semver");
6
6
  const types_1 = require("./types");
7
+ /**
8
+ * Generate a new assignment for a given user.
9
+ * Doesn't save it.
10
+ */
11
+ const generateUserAssignmentData = (experiment, userId, segmentationData) => {
12
+ const segmentationMatch = (0, exports.validateSegmentationRules)(experiment.rules, segmentationData);
13
+ if (!segmentationMatch)
14
+ return null;
15
+ const bucket = (0, exports.determineAssignment)(experiment.sampling, experiment.buckets);
16
+ return {
17
+ userId,
18
+ experimentId: experiment.id,
19
+ bucketId: bucket?.id || null,
20
+ };
21
+ };
22
+ exports.generateUserAssignmentData = generateUserAssignmentData;
7
23
  /**
8
24
  * Generate a random number between 0 and 100
9
25
  */
@@ -97,8 +113,22 @@ exports.validateSegmentationRule = validateSegmentationRule;
97
113
  /**
98
114
  * Returns true if an experiment is able to generate new assignments based on status and start/end dates
99
115
  */
100
- const canGenerateNewAssignments = (experiment) => {
101
- return (experiment.status === types_1.AssignmentStatus.Active &&
102
- (0, js_lib_1.localDate)().isBetween(experiment.startDateIncl, experiment.endDateExcl, '[)'));
116
+ const canGenerateNewAssignments = (experiment, exclusionSet) => {
117
+ return (!exclusionSet.has(experiment.id) &&
118
+ experiment.status === types_1.AssignmentStatus.Active &&
119
+ (0, js_lib_1.localDate)().isBetween((0, js_lib_1.localDate)(experiment.startDateIncl.toISOString()), (0, js_lib_1.localDate)(experiment.endDateExcl.toISOString()), '[)'));
103
120
  };
104
121
  exports.canGenerateNewAssignments = canGenerateNewAssignments;
122
+ /**
123
+ * Returns an object that includes keys of all experimentIds a user should not be assigned to
124
+ * based on a combination of existing assignments and mutual exclusion configuration
125
+ */
126
+ const getUserExclusionSet = (experiments, existingAssignments) => {
127
+ const exclusionSet = new Set();
128
+ existingAssignments.forEach(assignment => {
129
+ const experiment = experiments.find(e => e.id === assignment.experimentId);
130
+ experiment?.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
131
+ });
132
+ return exclusionSet;
133
+ };
134
+ exports.getUserExclusionSet = getUserExclusionSet;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naturalcycles/abba",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "scripts": {
5
5
  "prepare": "husky install",
6
6
  "build": "build",
package/readme.md CHANGED
@@ -27,6 +27,7 @@
27
27
  </li>
28
28
  <li><a href="#usage">Usage</a></li>
29
29
  <li><a href="#segmentation">Segmentation</a></li>
30
+ <li><a href="#exclusion">Mutual Exclusion</a></li>
30
31
  </ol>
31
32
  </details>
32
33
 
@@ -41,6 +42,8 @@
41
42
  - **Bucket:** An allocation that defines what variant of a particular experience the user will have
42
43
  - **Start/End dates:** The timeframe that assignments will be generated for this experiment when
43
44
  active
45
+ - **Mutual Exclusion:**
46
+ [See here](https://docs.developers.optimizely.com/full-stack-experimentation/docs/mutually-exclusive-experiments)
44
47
 
45
48
  <!-- BUILTWITH -->
46
49
 
@@ -287,3 +290,13 @@ Example segmentation data:
287
290
  ```
288
291
 
289
292
  <p align="right">(<a href="#top">back to top</a>)</p>
293
+
294
+ <div id="exclusion"></div>
295
+
296
+ ## Mutual Excluusion
297
+
298
+ Mutual exclusion is configured per-experiment. If an experiment is listed as mutually exclusive with
299
+ another experiment(s) then new assignments will only be generated with one of the experiments and
300
+ will never be created for the other(s)
301
+
302
+ <p align="right">(<a href="#top">back to top</a>)</p>
package/src/abba.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { _assert, _AsyncMemo, pMap, Saved } from '@naturalcycles/js-lib'
1
+ import { _assert, _AsyncMemo, _shuffle, pMap, Saved } from '@naturalcycles/js-lib'
2
2
  import { LRUMemoCache } from '@naturalcycles/nodejs-lib'
3
3
  import {
4
4
  AbbaConfig,
@@ -12,72 +12,42 @@ import {
12
12
  } from './types'
13
13
  import {
14
14
  canGenerateNewAssignments,
15
- determineAssignment,
16
- validateSegmentationRules,
15
+ generateUserAssignmentData,
16
+ getUserExclusionSet,
17
17
  validateTotalBucketRatio,
18
18
  } from './util'
19
- import { ExperimentDao, experimentDao } from './dao/experiment.dao'
20
- import { UserAssignmentDao, userAssignmentDao } from './dao/userAssignment.dao'
21
- import { BucketDao, bucketDao } from './dao/bucket.dao'
19
+ import { experimentDao } from './dao/experiment.dao'
20
+ import { userAssignmentDao } from './dao/userAssignment.dao'
21
+ import { bucketDao } from './dao/bucket.dao'
22
22
  import { SegmentationData, AssignmentStatistics } from '.'
23
23
 
24
+ /**
25
+ * 10 minutes
26
+ */
24
27
  const CACHE_TTL = 600_000 // 10 minutes
25
28
 
26
29
  export class Abba {
27
- constructor(public cfg: AbbaConfig) {
28
- const { db } = cfg
29
- this.experimentDao = experimentDao(db)
30
- this.bucketDao = bucketDao(db)
31
- this.userAssignmentDao = userAssignmentDao(db)
32
- }
30
+ private experimentDao = experimentDao(this.cfg.db)
31
+ private bucketDao = bucketDao(this.cfg.db)
32
+ private userAssignmentDao = userAssignmentDao(this.cfg.db)
33
33
 
34
- private experimentDao: ExperimentDao
35
- private bucketDao: BucketDao
36
- private userAssignmentDao: UserAssignmentDao
34
+ constructor(public cfg: AbbaConfig) {}
37
35
 
38
36
  /**
39
- * Returns all (active and inactive) experiments.
40
- *
41
- * Cold method, not cached.
37
+ * Returns all experiments.
38
+ * Cached (see CACHE_TTL)
42
39
  */
40
+ @_AsyncMemo({ cacheFactory: () => new LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
43
41
  async getAllExperiments(): Promise<ExperimentWithBuckets[]> {
44
- const experiments = await this.experimentDao.query().runQuery()
45
-
46
- const buckets = await this.bucketDao
47
- .query()
48
- .filter(
49
- 'experimentId',
50
- 'in',
51
- experiments.map(e => e.id),
52
- )
53
- .runQuery()
54
-
55
- return experiments.map(experiment => ({
56
- ...experiment,
57
- buckets: buckets.filter(bucket => bucket.experimentId === experiment.id),
58
- }))
42
+ return await this.getAllExperimentsNoCache()
59
43
  }
60
44
 
61
45
  /**
62
- * Returns only active experiments.
63
- * Hot method.
64
- * Cached in-memory for N minutes (currently 10).
46
+ * Returns all experiments.
65
47
  */
66
- @_AsyncMemo({ cacheFactory: () => new LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
67
- async getActiveExperiments(): Promise<ExperimentWithBuckets[]> {
68
- const experiments = await this.experimentDao
69
- .query()
70
- .filter('status', '!=', AssignmentStatus.Inactive)
71
- .runQuery()
72
-
73
- const buckets = await this.bucketDao
74
- .query()
75
- .filter(
76
- 'experimentId',
77
- 'in',
78
- experiments.map(e => e.id),
79
- )
80
- .runQuery()
48
+ async getAllExperimentsNoCache(): Promise<ExperimentWithBuckets[]> {
49
+ const experiments = await this.experimentDao.getAll()
50
+ const buckets = await this.bucketDao.getAll()
81
51
 
82
52
  return experiments.map(experiment => ({
83
53
  ...experiment,
@@ -97,13 +67,16 @@ export class Abba {
97
67
  validateTotalBucketRatio(buckets)
98
68
  }
99
69
 
100
- await this.experimentDao.save(experiment)
70
+ const created = await this.experimentDao.save(experiment)
71
+ const createdbuckets = await this.bucketDao.saveBatch(
72
+ buckets.map(b => ({ ...b, experimentId: experiment.id })),
73
+ )
74
+
75
+ await this.updateExclusions(created.id, created.exclusions)
101
76
 
102
77
  return {
103
- ...(experiment as Saved<Experiment>),
104
- buckets: await this.bucketDao.saveBatch(
105
- buckets.map(b => ({ ...b, experimentId: experiment.id })),
106
- ),
78
+ ...created,
79
+ buckets: createdbuckets,
107
80
  }
108
81
  }
109
82
 
@@ -119,22 +92,56 @@ export class Abba {
119
92
  validateTotalBucketRatio(buckets)
120
93
  }
121
94
 
122
- await this.experimentDao.save(experiment)
95
+ const updatedExperiment = await this.experimentDao.save(experiment)
96
+ const updatedBuckets = await this.bucketDao.saveBatch(
97
+ buckets.map(b => ({ ...b, experimentId: experiment.id })),
98
+ )
99
+
100
+ await this.updateExclusions(updatedExperiment.id, updatedExperiment.exclusions)
123
101
 
124
102
  return {
125
- ...(experiment as Saved<Experiment>),
126
- buckets: await this.bucketDao.saveBatch(
127
- buckets.map(b => ({ ...b, experimentId: experiment.id })),
128
- ),
103
+ ...updatedExperiment,
104
+ buckets: updatedBuckets,
129
105
  }
130
106
  }
131
107
 
108
+ /**
109
+ * Ensures that mutual exclusions are maintained
110
+ */
111
+ private async updateExclusions(experimentId: number, updatedExclusions: number[]): Promise<void> {
112
+ const experiments = await this.experimentDao.getAll()
113
+
114
+ const requiresUpdating: Experiment[] = []
115
+ experiments.map(experiment => {
116
+ // Make sure it's mutual
117
+ if (
118
+ updatedExclusions.includes(experiment.id) &&
119
+ !experiment.exclusions.includes(experimentId)
120
+ ) {
121
+ experiment.exclusions.push(experimentId)
122
+ requiresUpdating.push(experiment)
123
+ }
124
+
125
+ // Make sure it's mutual
126
+ if (
127
+ !updatedExclusions.includes(experiment.id) &&
128
+ experiment.exclusions.includes(experimentId)
129
+ ) {
130
+ experiment.exclusions = experiment.exclusions.filter(id => id !== experimentId)
131
+ requiresUpdating.push(experiment)
132
+ }
133
+ })
134
+
135
+ await this.experimentDao.saveBatch(requiresUpdating)
136
+ }
137
+
132
138
  /**
133
139
  * Delete an experiment. Removes all user assignments and buckets.
134
140
  * Cold method.
135
141
  */
136
- async deleteExperiment(id: number): Promise<void> {
137
- await this.experimentDao.deleteById(id)
142
+ async deleteExperiment(experimentId: number): Promise<void> {
143
+ await this.experimentDao.deleteById(experimentId)
144
+ await this.updateExclusions(experimentId, [])
138
145
  }
139
146
 
140
147
  /**
@@ -152,8 +159,9 @@ export class Abba {
152
159
  existingOnly: boolean,
153
160
  segmentationData?: SegmentationData,
154
161
  ): Promise<GeneratedUserAssignment | null> {
155
- const existing = await this.getExistingUserAssignment(experimentId, userId)
162
+ const existingAssignments = await this.getAllExistingUserAssignments(userId)
156
163
 
164
+ const existing = existingAssignments.find(a => a.experimentId === experimentId)
157
165
  if (existing) {
158
166
  let bucketKey = null
159
167
  if (existing.bucketId) {
@@ -165,25 +173,23 @@ export class Abba {
165
173
 
166
174
  if (existingOnly) return null
167
175
 
168
- const experiment = await this.experimentDao.requireById(experimentId)
169
- if (!canGenerateNewAssignments(experiment)) return null
176
+ const experiments = await this.getAllExperiments()
177
+ const experiment = experiments.find(e => e.id === experimentId)
178
+ _assert(experiment, `Experiment does not exist: ${experimentId}`)
170
179
 
171
- _assert(segmentationData, 'Segmentation data required when creating a new assignment')
180
+ const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
181
+ if (!canGenerateNewAssignments(experiment, exclusionSet)) return null
172
182
 
173
- const buckets = await this.bucketDao.getBy('experimentId', experimentId)
183
+ _assert(segmentationData, 'Segmentation data required when creating a new assignment')
174
184
 
175
- const assignment = this.generateUserAssignmentData(
176
- { ...experiment, buckets },
177
- userId,
178
- segmentationData,
179
- )
185
+ const assignment = generateUserAssignmentData(experiment, userId, segmentationData)
180
186
  if (!assignment) return null
181
187
 
182
188
  const saved = await this.userAssignmentDao.save(assignment)
183
189
 
184
190
  return {
185
191
  ...saved,
186
- bucketKey: buckets.find(b => b.id === saved.bucketId)?.key || null,
192
+ bucketKey: experiment.buckets.find(b => b.id === saved.bucketId)?.key || null,
187
193
  }
188
194
  }
189
195
 
@@ -206,21 +212,31 @@ export class Abba {
206
212
  segmentationData: SegmentationData,
207
213
  existingOnly = false,
208
214
  ): Promise<GeneratedUserAssignment[]> {
209
- const experiments = await this.getActiveExperiments() // cached
215
+ const experiments = await this.getAllExperiments()
216
+ const existingAssignments = await this.getAllExistingUserAssignments(userId)
217
+ const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
210
218
 
211
219
  const assignments: GeneratedUserAssignment[] = []
212
220
  const newAssignments: UserAssignment[] = []
213
- const existingAssignments = await this.getAllExistingUserAssignments(userId)
214
221
 
215
- for (const experiment of experiments) {
222
+ // Shuffling means that randomisation occurs in the mutual exclusion
223
+ // as experiments are looped through sequentially, this removes the risk of the same experiment always being assigned first in the list of mutually exclusive experiments
224
+ // This is simmpler than trying to resolve after assignments have already been determined
225
+ const availableExperiments = _shuffle(
226
+ experiments.filter(
227
+ e => e.status === AssignmentStatus.Active || e.status === AssignmentStatus.Paused,
228
+ ),
229
+ )
230
+
231
+ for (const experiment of availableExperiments) {
216
232
  const existing = existingAssignments.find(ua => ua.experimentId === experiment.id)
217
233
  if (existing) {
218
234
  assignments.push({
219
235
  ...existing,
220
236
  bucketKey: experiment.buckets.find(b => b.id === existing.bucketId)?.key || null,
221
237
  })
222
- } else if (!existingOnly && canGenerateNewAssignments(experiment)) {
223
- const assignment = this.generateUserAssignmentData(experiment, userId, segmentationData)
238
+ } else if (!existingOnly && canGenerateNewAssignments(experiment, exclusionSet)) {
239
+ const assignment = generateUserAssignmentData(experiment, userId, segmentationData)
224
240
  if (assignment) {
225
241
  const created = this.userAssignmentDao.create(assignment)
226
242
  newAssignments.push(created)
@@ -228,6 +244,8 @@ export class Abba {
228
244
  ...created,
229
245
  bucketKey: experiment.buckets.find(b => b.id === created.bucketId)?.key || null,
230
246
  })
247
+ // Prevent future exclusion clashes
248
+ experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
231
249
  }
232
250
  }
233
251
  }
@@ -259,41 +277,4 @@ export class Abba {
259
277
 
260
278
  return statistics
261
279
  }
262
-
263
- /**
264
- * Generate a new assignment for a given user.
265
- * Doesn't save it.
266
- */
267
- private generateUserAssignmentData(
268
- experiment: ExperimentWithBuckets,
269
- userId: string,
270
- segmentationData: SegmentationData,
271
- ): UserAssignment | null {
272
- const segmentationMatch = validateSegmentationRules(experiment.rules, segmentationData)
273
- if (!segmentationMatch) return null
274
-
275
- const bucket = determineAssignment(experiment.sampling, experiment.buckets)
276
-
277
- return {
278
- userId,
279
- experimentId: experiment.id,
280
- bucketId: bucket?.id || null,
281
- }
282
- }
283
-
284
- /**
285
- * Queries to retrieve an existing user assignment for a given experiment
286
- */
287
- private async getExistingUserAssignment(
288
- experimentId: number,
289
- userId: string,
290
- ): Promise<Saved<UserAssignment> | null> {
291
- const [assignment] = await this.userAssignmentDao
292
- .query()
293
- .filterEq('userId', userId)
294
- .filterEq('experimentId', experimentId)
295
- .runQuery()
296
-
297
- return assignment || null
298
- }
299
280
  }
@@ -4,6 +4,7 @@ import { BaseExperiment, Experiment } from '../types'
4
4
 
5
5
  type ExperimentDBM = Saved<BaseExperiment> & {
6
6
  rules: string | null
7
+ exclusions: string | null
7
8
  }
8
9
 
9
10
  export class ExperimentDao extends CommonDao<Experiment, ExperimentDBM> {}
@@ -16,7 +17,15 @@ export const experimentDao = (db: CommonDB): ExperimentDao =>
16
17
  idType: 'number',
17
18
  assignGeneratedIds: true,
18
19
  hooks: {
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
+ beforeBMToDBM: bm => ({
21
+ ...bm,
22
+ rules: bm.rules.length ? JSON.stringify(bm.rules) : null,
23
+ exclusions: bm.exclusions.length ? JSON.stringify(bm.exclusions) : null,
24
+ }),
25
+ beforeDBMToBM: dbm => ({
26
+ ...dbm,
27
+ rules: (dbm.rules && JSON.parse(dbm.rules)) || [],
28
+ exclusions: (dbm.exclusions && JSON.parse(dbm.exclusions)) || [],
29
+ }),
21
30
  },
22
31
  })
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS `Experiment` (
21
21
  `created` INTEGER(11) NOT NULL,
22
22
  `updated` INTEGER(11) NOT NULL,
23
23
  `rules` JSON NULL,
24
+ `exclusions` JSON NULL,
24
25
 
25
26
  PRIMARY KEY (`id`)
26
27
  ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
package/src/types.ts CHANGED
@@ -1,16 +1,8 @@
1
1
  import { CommonDB } from '@naturalcycles/db-lib'
2
- import { BaseDBEntity, IsoDateString, Saved } from '@naturalcycles/js-lib'
2
+ import { AnyObject, 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
14
6
  }
15
7
 
16
8
  export type BaseExperiment = BaseDBEntity<number> & {
@@ -18,12 +10,13 @@ export type BaseExperiment = BaseDBEntity<number> & {
18
10
  status: number
19
11
  sampling: number
20
12
  description: string | null
21
- startDateIncl: IsoDateString
22
- endDateExcl: IsoDateString
13
+ startDateIncl: Date
14
+ endDateExcl: Date
23
15
  }
24
16
 
25
17
  export type Experiment = BaseExperiment & {
26
18
  rules: SegmentationRule[]
19
+ exclusions: number[]
27
20
  }
28
21
 
29
22
  export type ExperimentWithBuckets = Saved<Experiment> & {
@@ -36,11 +29,7 @@ export interface BucketInput {
36
29
  ratio: number
37
30
  }
38
31
 
39
- export type Bucket = BaseDBEntity<number> & {
40
- experimentId: number
41
- key: string
42
- ratio: number
43
- }
32
+ export type Bucket = BaseDBEntity<number> & BucketInput
44
33
 
45
34
  export type UserAssignment = BaseDBEntity<number> & {
46
35
  userId: string
@@ -52,7 +41,7 @@ export type GeneratedUserAssignment = Saved<UserAssignment> & {
52
41
  bucketKey: string | null
53
42
  }
54
43
 
55
- export type SegmentationData = Record<string, string | boolean | number>
44
+ export type SegmentationData = AnyObject
56
45
 
57
46
  export enum AssignmentStatus {
58
47
  /**
@@ -81,3 +70,5 @@ export interface AssignmentStatistics {
81
70
  [id: string]: number
82
71
  }
83
72
  }
73
+
74
+ export type ExclusionSet = Set<number>
package/src/util.ts CHANGED
@@ -1,6 +1,36 @@
1
1
  import { localDate, Saved } from '@naturalcycles/js-lib'
2
2
  import { satisfies } from 'semver'
3
- import { AssignmentStatus, Bucket, Experiment, SegmentationData, SegmentationRule } from './types'
3
+ import {
4
+ AssignmentStatus,
5
+ Bucket,
6
+ ExclusionSet,
7
+ Experiment,
8
+ ExperimentWithBuckets,
9
+ SegmentationData,
10
+ SegmentationRule,
11
+ UserAssignment,
12
+ } from './types'
13
+
14
+ /**
15
+ * Generate a new assignment for a given user.
16
+ * Doesn't save it.
17
+ */
18
+ export const generateUserAssignmentData = (
19
+ experiment: ExperimentWithBuckets,
20
+ userId: string,
21
+ segmentationData: SegmentationData,
22
+ ): UserAssignment | null => {
23
+ const segmentationMatch = validateSegmentationRules(experiment.rules, segmentationData)
24
+ if (!segmentationMatch) return null
25
+
26
+ const bucket = determineAssignment(experiment.sampling, experiment.buckets)
27
+
28
+ return {
29
+ userId,
30
+ experimentId: experiment.id,
31
+ bucketId: bucket?.id || null,
32
+ }
33
+ }
4
34
 
5
35
  /**
6
36
  * Generate a random number between 0 and 100
@@ -99,9 +129,33 @@ export const validateSegmentationRule = (
99
129
  /**
100
130
  * Returns true if an experiment is able to generate new assignments based on status and start/end dates
101
131
  */
102
- export const canGenerateNewAssignments = (experiment: Experiment): boolean => {
132
+ export const canGenerateNewAssignments = (
133
+ experiment: Experiment,
134
+ exclusionSet: ExclusionSet,
135
+ ): boolean => {
103
136
  return (
137
+ !exclusionSet.has(experiment.id) &&
104
138
  experiment.status === AssignmentStatus.Active &&
105
- localDate().isBetween(experiment.startDateIncl, experiment.endDateExcl, '[)')
139
+ localDate().isBetween(
140
+ localDate(experiment.startDateIncl.toISOString()),
141
+ localDate(experiment.endDateExcl.toISOString()),
142
+ '[)',
143
+ )
106
144
  )
107
145
  }
146
+
147
+ /**
148
+ * Returns an object that includes keys of all experimentIds a user should not be assigned to
149
+ * based on a combination of existing assignments and mutual exclusion configuration
150
+ */
151
+ export const getUserExclusionSet = (
152
+ experiments: Experiment[],
153
+ existingAssignments: UserAssignment[],
154
+ ): ExclusionSet => {
155
+ const exclusionSet: ExclusionSet = new Set()
156
+ existingAssignments.forEach(assignment => {
157
+ const experiment = experiments.find(e => e.id === assignment.experimentId)
158
+ experiment?.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
159
+ })
160
+ return exclusionSet
161
+ }