SplitTestManager.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. const { SplitTest } = require('../../models/SplitTest')
  2. const SplitTestUtils = require('./SplitTestUtils')
  3. const OError = require('@overleaf/o-error')
  4. const _ = require('lodash')
  5. const { CacheFlow } = require('cache-flow')
  6. const ALPHA_PHASE = 'alpha'
  7. const BETA_PHASE = 'beta'
  8. const RELEASE_PHASE = 'release'
  9. async function getSplitTests({ name, phase, type, active, archived }) {
  10. const filters = {}
  11. if (name && name !== '') {
  12. filters.name = { $regex: _.escapeRegExp(name) }
  13. }
  14. if (active) {
  15. filters.$where = 'this.versions[this.versions.length - 1].active === true'
  16. }
  17. if (type === 'split-test') {
  18. const query =
  19. 'this.versions[this.versions.length - 1].analyticsEnabled === true'
  20. if (filters.$where) {
  21. filters.$where += `&& ${query}`
  22. } else {
  23. filters.$where = query
  24. }
  25. }
  26. if (type === 'gradual-rollout') {
  27. const query =
  28. 'this.versions[this.versions.length - 1].analyticsEnabled === false'
  29. if (filters.$where) {
  30. filters.$where += `&& ${query}`
  31. } else {
  32. filters.$where = query
  33. }
  34. }
  35. if (['alpha', 'beta', 'release'].includes(phase)) {
  36. const query = `this.versions[this.versions.length - 1].phase === "${phase}"`
  37. if (filters.$where) {
  38. filters.$where += `&& ${query}`
  39. } else {
  40. filters.$where = query
  41. }
  42. }
  43. if (archived === true) {
  44. filters.archived = true
  45. } else if (archived === false) {
  46. filters.archived = { $ne: true }
  47. }
  48. try {
  49. return await SplitTest.find(filters)
  50. .populate('archivedBy', ['email', 'first_name', 'last_name'])
  51. .populate('versions.author', ['email', 'first_name', 'last_name'])
  52. .limit(300)
  53. .exec()
  54. } catch (error) {
  55. throw OError.tag(error, 'Failed to get split tests list')
  56. }
  57. }
  58. async function getRuntimeTests() {
  59. try {
  60. return await SplitTest.find({
  61. archived: { $ne: true },
  62. }).exec()
  63. } catch (error) {
  64. throw OError.tag(error, 'Failed to get active split tests list')
  65. }
  66. }
  67. async function getSplitTest(query) {
  68. try {
  69. return await SplitTest.findOne(query)
  70. .populate('archivedBy', ['email', 'first_name', 'last_name'])
  71. .populate('versions.author', ['email', 'first_name', 'last_name'])
  72. .exec()
  73. } catch (error) {
  74. throw OError.tag(error, 'Failed to get split test', { query })
  75. }
  76. }
  77. async function createSplitTest(
  78. { name, configuration, badgeInfo = {}, info = {} },
  79. userId
  80. ) {
  81. const stripedVariants = []
  82. let stripeStart = 0
  83. _checkNewVariantsConfiguration([], configuration.variants)
  84. for (const variant of configuration.variants) {
  85. stripedVariants.push({
  86. name: (variant.name || '').trim(),
  87. rolloutPercent: variant.rolloutPercent,
  88. rolloutStripes:
  89. variant.rolloutPercent > 0
  90. ? [
  91. {
  92. start: stripeStart,
  93. end: stripeStart + variant.rolloutPercent,
  94. },
  95. ]
  96. : [],
  97. })
  98. stripeStart += variant.rolloutPercent
  99. }
  100. const splitTest = new SplitTest({
  101. name: (name || '').trim(),
  102. description: info.description,
  103. ticketUrl: info.ticketUrl,
  104. reportsUrls: info.reportsUrls,
  105. winningVariant: info.winningVariant,
  106. badgeInfo,
  107. versions: [
  108. {
  109. versionNumber: 1,
  110. phase: configuration.phase,
  111. active: configuration.active,
  112. analyticsEnabled:
  113. configuration.active && configuration.analyticsEnabled,
  114. variants: stripedVariants,
  115. author: userId,
  116. },
  117. ],
  118. expectedEndDate: info.expectedEndDate,
  119. expectedUplift: info.expectedUplift,
  120. requiredCohortSize: info.requiredCohortSize,
  121. })
  122. return _saveSplitTest(splitTest)
  123. }
  124. async function updateSplitTestConfig({ name, configuration, comment }, userId) {
  125. const splitTest = await getSplitTest({ name })
  126. if (!splitTest) {
  127. throw new OError(`Cannot update split test '${name}': not found`)
  128. }
  129. if (splitTest.archived) {
  130. throw new OError('Cannot update an archived split test', { name })
  131. }
  132. const lastVersion = SplitTestUtils.getCurrentVersion(splitTest).toObject()
  133. if (configuration.phase !== lastVersion.phase) {
  134. throw new OError(
  135. `Cannot update with different phase - use /switch-to-next-phase endpoint instead`
  136. )
  137. }
  138. _checkNewVariantsConfiguration(lastVersion.variants, configuration.variants)
  139. const updatedVariants = _updateVariantsWithNewConfiguration(
  140. lastVersion.variants,
  141. configuration.variants
  142. )
  143. splitTest.versions.push({
  144. versionNumber: lastVersion.versionNumber + 1,
  145. phase: configuration.phase,
  146. active: configuration.active,
  147. analyticsEnabled: configuration.analyticsEnabled,
  148. variants: updatedVariants,
  149. author: userId,
  150. comment,
  151. })
  152. return _saveSplitTest(splitTest)
  153. }
  154. async function updateSplitTestInfo(name, info) {
  155. const splitTest = await getSplitTest({ name })
  156. if (!splitTest) {
  157. throw new OError(`Cannot update split test '${name}': not found`)
  158. }
  159. splitTest.description = info.description
  160. splitTest.expectedEndDate = info.expectedEndDate
  161. splitTest.ticketUrl = info.ticketUrl
  162. splitTest.reportsUrls = info.reportsUrls
  163. splitTest.winningVariant = info.winningVariant
  164. return _saveSplitTest(splitTest)
  165. }
  166. async function updateSplitTestBadgeInfo(name, badgeInfo) {
  167. const splitTest = await getSplitTest({ name })
  168. if (!splitTest) {
  169. throw new OError(`Cannot update split test '${name}': not found`)
  170. }
  171. splitTest.badgeInfo = badgeInfo
  172. return _saveSplitTest(splitTest)
  173. }
  174. async function replaceSplitTests(tests) {
  175. _checkEnvIsSafe('replace')
  176. try {
  177. await _deleteSplitTests()
  178. return await SplitTest.create(tests)
  179. } catch (error) {
  180. throw OError.tag(error, 'Failed to replace all split tests', { tests })
  181. }
  182. }
  183. async function mergeSplitTests(incomingTests, overWriteLocal) {
  184. _checkEnvIsSafe('merge')
  185. // this is required as the query returns models, and we need all the items to be objects,
  186. // similar to the ones we recieve as incomingTests
  187. const localTests = await SplitTest.find({}).lean().exec()
  188. let merged
  189. // we preserve the state of the local tests (baseTests)
  190. // eg: if inTest is in phase 1, and basetest is in phase 2, the merged will be in state 2
  191. // therefore, we can have the opposite effect by swapping the order of args (overwrite locals with sent tests)
  192. if (overWriteLocal) {
  193. merged = _mergeFlags(localTests, incomingTests)
  194. } else {
  195. merged = _mergeFlags(incomingTests, localTests)
  196. }
  197. try {
  198. await _deleteSplitTests()
  199. const success = await SplitTest.create(merged)
  200. return success
  201. } catch (error) {
  202. throw OError.tag(error, 'Failed to merge all split tests, merged set was', {
  203. merged,
  204. })
  205. }
  206. }
  207. async function switchToNextPhase({ name, comment }, userId) {
  208. const splitTest = await getSplitTest({ name })
  209. if (!splitTest) {
  210. throw new OError(
  211. `Cannot switch split test with ID '${name}' to next phase: not found`
  212. )
  213. }
  214. if (splitTest.archived) {
  215. throw new OError('Cannot switch an archived split test to next phase', {
  216. name,
  217. })
  218. }
  219. const lastVersionCopy = SplitTestUtils.getCurrentVersion(splitTest).toObject()
  220. lastVersionCopy.versionNumber++
  221. if (lastVersionCopy.phase === ALPHA_PHASE) {
  222. lastVersionCopy.phase = BETA_PHASE
  223. } else if (lastVersionCopy.phase === BETA_PHASE) {
  224. if (splitTest.forbidReleasePhase) {
  225. throw new OError('Switch to release phase is disabled for this test', {
  226. name,
  227. })
  228. }
  229. lastVersionCopy.phase = RELEASE_PHASE
  230. } else if (splitTest.phase === RELEASE_PHASE) {
  231. throw new OError(
  232. `Split test with ID '${name}' is already in the release phase`
  233. )
  234. }
  235. for (const variant of lastVersionCopy.variants) {
  236. variant.rolloutPercent = 0
  237. variant.rolloutStripes = []
  238. }
  239. lastVersionCopy.author = userId
  240. lastVersionCopy.comment = comment
  241. lastVersionCopy.createdAt = new Date()
  242. splitTest.versions.push(lastVersionCopy)
  243. return _saveSplitTest(splitTest)
  244. }
  245. async function revertToPreviousVersion(
  246. { name, versionNumber, comment },
  247. userId
  248. ) {
  249. const splitTest = await getSplitTest({ name })
  250. if (!splitTest) {
  251. throw new OError(
  252. `Cannot revert split test with ID '${name}' to previous version: not found`
  253. )
  254. }
  255. if (splitTest.archived) {
  256. throw new OError(
  257. 'Cannot revert an archived split test to previous version',
  258. {
  259. name,
  260. }
  261. )
  262. }
  263. if (splitTest.versions.length <= 1) {
  264. throw new OError(
  265. `Cannot revert split test with ID '${name}' to previous version: split test must have at least 2 versions`
  266. )
  267. }
  268. const previousVersion = SplitTestUtils.getVersion(splitTest, versionNumber)
  269. if (!previousVersion) {
  270. throw new OError(
  271. `Cannot revert split test with ID '${name}' to version number ${versionNumber}: version not found`
  272. )
  273. }
  274. const lastVersion = SplitTestUtils.getCurrentVersion(splitTest)
  275. if (
  276. lastVersion.phase === RELEASE_PHASE &&
  277. previousVersion.phase !== RELEASE_PHASE
  278. ) {
  279. splitTest.forbidReleasePhase = true
  280. }
  281. const previousVersionCopy = previousVersion.toObject()
  282. previousVersionCopy.versionNumber = lastVersion.versionNumber + 1
  283. previousVersionCopy.createdAt = new Date()
  284. previousVersionCopy.author = userId
  285. previousVersionCopy.comment = comment
  286. splitTest.versions.push(previousVersionCopy)
  287. return _saveSplitTest(splitTest)
  288. }
  289. async function archive(name, userId) {
  290. const splitTest = await getSplitTest({ name })
  291. if (!splitTest) {
  292. throw new OError(`Cannot archive split test with ID '${name}': not found`)
  293. }
  294. if (splitTest.archived) {
  295. throw new OError(`Split test with ID '${name}' is already archived`)
  296. }
  297. splitTest.archived = true
  298. splitTest.archivedAt = new Date()
  299. splitTest.archivedBy = userId
  300. return _saveSplitTest(splitTest)
  301. }
  302. async function clearCache() {
  303. await CacheFlow.reset('split-test')
  304. }
  305. function _checkNewVariantsConfiguration(variants, newVariantsConfiguration) {
  306. const totalRolloutPercentage = _getTotalRolloutPercentage(
  307. newVariantsConfiguration
  308. )
  309. if (totalRolloutPercentage > 100) {
  310. throw new OError(`Total variants rollout percentage cannot exceed 100`)
  311. }
  312. for (const variant of variants) {
  313. const newVariantConfiguration = _.find(newVariantsConfiguration, {
  314. name: variant.name,
  315. })
  316. if (!newVariantConfiguration) {
  317. throw new OError(
  318. `Variant defined in previous version as ${JSON.stringify(
  319. variant
  320. )} cannot be removed in new configuration: either set it inactive or create a new split test`
  321. )
  322. }
  323. if (newVariantConfiguration.rolloutPercent < variant.rolloutPercent) {
  324. throw new OError(
  325. `Rollout percentage for variant defined in previous version as ${JSON.stringify(
  326. variant
  327. )} cannot be decreased: revert to a previous configuration instead`
  328. )
  329. }
  330. }
  331. }
  332. function _updateVariantsWithNewConfiguration(
  333. variants,
  334. newVariantsConfiguration
  335. ) {
  336. let totalRolloutPercentage = _getTotalRolloutPercentage(variants)
  337. const variantsCopy = _.clone(variants)
  338. for (const newVariantConfig of newVariantsConfiguration) {
  339. if (newVariantConfig.rolloutPercent === 0) {
  340. continue
  341. }
  342. const variant = _.find(variantsCopy, { name: newVariantConfig.name })
  343. if (!variant) {
  344. variantsCopy.push({
  345. name: newVariantConfig.name,
  346. rolloutPercent: newVariantConfig.rolloutPercent,
  347. rolloutStripes: [
  348. {
  349. start: totalRolloutPercentage,
  350. end: totalRolloutPercentage + newVariantConfig.rolloutPercent,
  351. },
  352. ],
  353. })
  354. totalRolloutPercentage += newVariantConfig.rolloutPercent
  355. } else if (variant.rolloutPercent < newVariantConfig.rolloutPercent) {
  356. const newStripeSize =
  357. newVariantConfig.rolloutPercent - variant.rolloutPercent
  358. variant.rolloutPercent = newVariantConfig.rolloutPercent
  359. variant.rolloutStripes.push({
  360. start: totalRolloutPercentage,
  361. end: totalRolloutPercentage + newStripeSize,
  362. })
  363. totalRolloutPercentage += newStripeSize
  364. }
  365. }
  366. return variantsCopy
  367. }
  368. function _getTotalRolloutPercentage(variants) {
  369. return _.sumBy(variants, 'rolloutPercent')
  370. }
  371. async function _saveSplitTest(splitTest) {
  372. try {
  373. const savedSplitTest = await splitTest.save()
  374. await savedSplitTest.populate('archivedBy', [
  375. 'email',
  376. 'first_name',
  377. 'last_name',
  378. ])
  379. await savedSplitTest.populate('versions.author', [
  380. 'email',
  381. 'first_name',
  382. 'last_name',
  383. ])
  384. return savedSplitTest.toObject()
  385. } catch (error) {
  386. throw OError.tag(error, 'Failed to save split test', {
  387. splitTest: JSON.stringify(splitTest),
  388. })
  389. }
  390. }
  391. /*
  392. * As this is only used for utility in local dev environment, we should make sure this isn't run in
  393. * any other deployment environment.
  394. */
  395. function _checkEnvIsSafe(operation) {
  396. if (process.env.NODE_ENV !== 'development') {
  397. throw new OError(
  398. `Attempted to ${operation} all feature flags outside of local env`
  399. )
  400. }
  401. }
  402. async function _deleteSplitTests() {
  403. _checkEnvIsSafe('delete')
  404. let deleted
  405. try {
  406. deleted = await SplitTest.deleteMany({}).exec()
  407. } catch (error) {
  408. throw new OError('Failed to delete all split tests')
  409. }
  410. if (!deleted.acknowledged) {
  411. throw new OError('Error deleting split tests, split tests have not updated')
  412. }
  413. }
  414. function _mergeFlags(incomingTests, baseTests) {
  415. // copy all base versions
  416. const mergedSet = baseTests.map(test => test)
  417. for (const inTest of incomingTests) {
  418. // since name is a unique key, we can use it to compare
  419. const newFeatureFlag = !mergedSet.some(bTest => bTest.name === inTest.name)
  420. // only add new feature flags, instead of overwriting ones in baseTests, meaning baseTests take precendence
  421. if (newFeatureFlag) {
  422. mergedSet.push(inTest)
  423. }
  424. }
  425. return mergedSet
  426. }
  427. module.exports = {
  428. getSplitTest,
  429. getSplitTests,
  430. getRuntimeTests,
  431. createSplitTest,
  432. updateSplitTestConfig,
  433. updateSplitTestInfo,
  434. updateSplitTestBadgeInfo,
  435. switchToNextPhase,
  436. revertToPreviousVersion,
  437. archive,
  438. replaceSplitTests,
  439. mergeSplitTests,
  440. clearCache,
  441. }