SplitTestManager.js 14 KB

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