SplitTestManager.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. const { SplitTest } = require('../../models/SplitTest')
  2. const SplitTestUtils = require('./SplitTestUtils')
  3. const OError = require('@overleaf/o-error')
  4. const _ = require('lodash')
  5. const ALPHA_PHASE = 'alpha'
  6. const BETA_PHASE = 'beta'
  7. const RELEASE_PHASE = 'release'
  8. async function getSplitTests({ name, phase, type, active, archived }) {
  9. const filters = {}
  10. if (name && name !== '') {
  11. filters.name = { $regex: _.escapeRegExp(name) }
  12. }
  13. if (active) {
  14. filters.$where = 'this.versions[this.versions.length - 1].active === true'
  15. }
  16. if (type === 'split-test') {
  17. const query =
  18. 'this.versions[this.versions.length - 1].analyticsEnabled === true'
  19. if (filters.$where) {
  20. filters.$where += `&& ${query}`
  21. } else {
  22. filters.$where = query
  23. }
  24. }
  25. if (type === 'gradual-rollout') {
  26. const query =
  27. 'this.versions[this.versions.length - 1].analyticsEnabled === false'
  28. if (filters.$where) {
  29. filters.$where += `&& ${query}`
  30. } else {
  31. filters.$where = query
  32. }
  33. }
  34. if (['alpha', 'beta', 'release'].includes(phase)) {
  35. const query = `this.versions[this.versions.length - 1].phase === "${phase}"`
  36. if (filters.$where) {
  37. filters.$where += `&& ${query}`
  38. } else {
  39. filters.$where = query
  40. }
  41. }
  42. if (archived === true) {
  43. filters.archived = true
  44. } else if (archived === false) {
  45. filters.archived = { $ne: true }
  46. }
  47. try {
  48. return await SplitTest.find(filters).limit(100).exec()
  49. } catch (error) {
  50. throw OError.tag(error, 'Failed to get split tests list')
  51. }
  52. }
  53. async function getSplitTest(query) {
  54. try {
  55. return await SplitTest.findOne(query).exec()
  56. } catch (error) {
  57. throw OError.tag(error, 'Failed to get split test', { query })
  58. }
  59. }
  60. async function createSplitTest({
  61. name,
  62. configuration,
  63. badgeInfo = {},
  64. info = {},
  65. }) {
  66. const stripedVariants = []
  67. let stripeStart = 0
  68. _checkNewVariantsConfiguration([], configuration.variants)
  69. for (const variant of configuration.variants) {
  70. stripedVariants.push({
  71. name: (variant.name || '').trim(),
  72. rolloutPercent: variant.rolloutPercent,
  73. rolloutStripes:
  74. variant.rolloutPercent > 0
  75. ? [
  76. {
  77. start: stripeStart,
  78. end: stripeStart + variant.rolloutPercent,
  79. },
  80. ]
  81. : [],
  82. })
  83. stripeStart += variant.rolloutPercent
  84. }
  85. const splitTest = new SplitTest({
  86. name: (name || '').trim(),
  87. description: info.description,
  88. expectedEndDate: info.expectedEndDate,
  89. ticketUrl: info.ticketUrl,
  90. reportsUrls: info.reportsUrls,
  91. winningVariant: info.winningVariant,
  92. badgeInfo,
  93. versions: [
  94. {
  95. versionNumber: 1,
  96. phase: configuration.phase,
  97. active: configuration.active,
  98. analyticsEnabled:
  99. configuration.active && configuration.analyticsEnabled,
  100. variants: stripedVariants,
  101. },
  102. ],
  103. })
  104. return _saveSplitTest(splitTest)
  105. }
  106. async function updateSplitTestConfig(name, configuration) {
  107. const splitTest = await getSplitTest({ name })
  108. if (!splitTest) {
  109. throw new OError(`Cannot update split test '${name}': not found`)
  110. }
  111. if (splitTest.archived) {
  112. throw new OError('Cannot update an archived split test', { name })
  113. }
  114. const lastVersion = SplitTestUtils.getCurrentVersion(splitTest).toObject()
  115. if (configuration.phase !== lastVersion.phase) {
  116. throw new OError(
  117. `Cannot update with different phase - use /switch-to-next-phase endpoint instead`
  118. )
  119. }
  120. _checkNewVariantsConfiguration(lastVersion.variants, configuration.variants)
  121. const updatedVariants = _updateVariantsWithNewConfiguration(
  122. lastVersion.variants,
  123. configuration.variants
  124. )
  125. splitTest.versions.push({
  126. versionNumber: lastVersion.versionNumber + 1,
  127. phase: configuration.phase,
  128. active: configuration.active,
  129. analyticsEnabled: configuration.active && configuration.analyticsEnabled,
  130. variants: updatedVariants,
  131. })
  132. return _saveSplitTest(splitTest)
  133. }
  134. async function updateSplitTestInfo(name, info) {
  135. const splitTest = await getSplitTest({ name })
  136. if (!splitTest) {
  137. throw new OError(`Cannot update split test '${name}': not found`)
  138. }
  139. splitTest.description = info.description
  140. splitTest.expectedEndDate = info.expectedEndDate
  141. splitTest.ticketUrl = info.ticketUrl
  142. splitTest.reportsUrls = info.reportsUrls
  143. splitTest.winningVariant = info.winningVariant
  144. return _saveSplitTest(splitTest)
  145. }
  146. async function updateSplitTestBadgeInfo(name, badgeInfo) {
  147. const splitTest = await getSplitTest({ name })
  148. if (!splitTest) {
  149. throw new OError(`Cannot update split test '${name}': not found`)
  150. }
  151. splitTest.badgeInfo = badgeInfo
  152. return _saveSplitTest(splitTest)
  153. }
  154. async function switchToNextPhase(name) {
  155. const splitTest = await getSplitTest({ name })
  156. if (!splitTest) {
  157. throw new OError(
  158. `Cannot switch split test with ID '${name}' to next phase: not found`
  159. )
  160. }
  161. if (splitTest.archived) {
  162. throw new OError('Cannot switch an archived split test to next phase', {
  163. name,
  164. })
  165. }
  166. const lastVersionCopy = SplitTestUtils.getCurrentVersion(splitTest).toObject()
  167. lastVersionCopy.versionNumber++
  168. if (lastVersionCopy.phase === ALPHA_PHASE) {
  169. lastVersionCopy.phase = BETA_PHASE
  170. } else if (lastVersionCopy.phase === BETA_PHASE) {
  171. if (splitTest.forbidReleasePhase) {
  172. throw new OError('Switch to release phase is disabled for this test', {
  173. name,
  174. })
  175. }
  176. lastVersionCopy.phase = RELEASE_PHASE
  177. } else if (splitTest.phase === RELEASE_PHASE) {
  178. throw new OError(
  179. `Split test with ID '${name}' is already in the release phase`
  180. )
  181. }
  182. for (const variant of lastVersionCopy.variants) {
  183. variant.rolloutPercent = 0
  184. variant.rolloutStripes = []
  185. }
  186. splitTest.versions.push(lastVersionCopy)
  187. return _saveSplitTest(splitTest)
  188. }
  189. async function revertToPreviousVersion(name, versionNumber) {
  190. const splitTest = await getSplitTest({ name })
  191. if (!splitTest) {
  192. throw new OError(
  193. `Cannot revert split test with ID '${name}' to previous version: not found`
  194. )
  195. }
  196. if (splitTest.archived) {
  197. throw new OError(
  198. 'Cannot revert an archived split test to previous version',
  199. {
  200. name,
  201. }
  202. )
  203. }
  204. if (splitTest.versions.length <= 1) {
  205. throw new OError(
  206. `Cannot revert split test with ID '${name}' to previous version: split test must have at least 2 versions`
  207. )
  208. }
  209. const previousVersion = SplitTestUtils.getVersion(splitTest, versionNumber)
  210. if (!previousVersion) {
  211. throw new OError(
  212. `Cannot revert split test with ID '${name}' to version number ${versionNumber}: version not found`
  213. )
  214. }
  215. const lastVersion = SplitTestUtils.getCurrentVersion(splitTest)
  216. if (
  217. lastVersion.phase === RELEASE_PHASE &&
  218. previousVersion.phase !== RELEASE_PHASE
  219. ) {
  220. splitTest.forbidReleasePhase = true
  221. }
  222. const previousVersionCopy = previousVersion.toObject()
  223. previousVersionCopy.versionNumber = lastVersion.versionNumber + 1
  224. previousVersionCopy.createdAt = new Date()
  225. splitTest.versions.push(previousVersionCopy)
  226. return _saveSplitTest(splitTest)
  227. }
  228. async function archive(name) {
  229. const splitTest = await getSplitTest({ name })
  230. if (!splitTest) {
  231. throw new OError(`Cannot archive split test with ID '${name}': not found`)
  232. }
  233. if (splitTest.archived) {
  234. throw new OError(`Split test with ID '${name}' is already archived`)
  235. }
  236. splitTest.archived = true
  237. splitTest.archivedAt = new Date()
  238. return _saveSplitTest(splitTest)
  239. }
  240. function _checkNewVariantsConfiguration(variants, newVariantsConfiguration) {
  241. const totalRolloutPercentage = _getTotalRolloutPercentage(
  242. newVariantsConfiguration
  243. )
  244. if (totalRolloutPercentage > 100) {
  245. throw new OError(`Total variants rollout percentage cannot exceed 100`)
  246. }
  247. for (const variant of variants) {
  248. const newVariantConfiguration = _.find(newVariantsConfiguration, {
  249. name: variant.name,
  250. })
  251. if (!newVariantConfiguration) {
  252. throw new OError(
  253. `Variant defined in previous version as ${JSON.stringify(
  254. variant
  255. )} cannot be removed in new configuration: either set it inactive or create a new split test`
  256. )
  257. }
  258. if (newVariantConfiguration.rolloutPercent < variant.rolloutPercent) {
  259. throw new OError(
  260. `Rollout percentage for variant defined in previous version as ${JSON.stringify(
  261. variant
  262. )} cannot be decreased: revert to a previous configuration instead`
  263. )
  264. }
  265. }
  266. }
  267. function _updateVariantsWithNewConfiguration(
  268. variants,
  269. newVariantsConfiguration
  270. ) {
  271. let totalRolloutPercentage = _getTotalRolloutPercentage(variants)
  272. const variantsCopy = _.clone(variants)
  273. for (const newVariantConfig of newVariantsConfiguration) {
  274. if (newVariantConfig.rolloutPercent === 0) {
  275. continue
  276. }
  277. const variant = _.find(variantsCopy, { name: newVariantConfig.name })
  278. if (!variant) {
  279. variantsCopy.push({
  280. name: newVariantConfig.name,
  281. rolloutPercent: newVariantConfig.rolloutPercent,
  282. rolloutStripes: [
  283. {
  284. start: totalRolloutPercentage,
  285. end: totalRolloutPercentage + newVariantConfig.rolloutPercent,
  286. },
  287. ],
  288. })
  289. totalRolloutPercentage += newVariantConfig.rolloutPercent
  290. } else if (variant.rolloutPercent < newVariantConfig.rolloutPercent) {
  291. const newStripeSize =
  292. newVariantConfig.rolloutPercent - variant.rolloutPercent
  293. variant.rolloutPercent = newVariantConfig.rolloutPercent
  294. variant.rolloutStripes.push({
  295. start: totalRolloutPercentage,
  296. end: totalRolloutPercentage + newStripeSize,
  297. })
  298. totalRolloutPercentage += newStripeSize
  299. }
  300. }
  301. return variantsCopy
  302. }
  303. function _getTotalRolloutPercentage(variants) {
  304. return _.sumBy(variants, 'rolloutPercent')
  305. }
  306. async function _saveSplitTest(splitTest) {
  307. try {
  308. return (await splitTest.save()).toObject()
  309. } catch (error) {
  310. throw OError.tag(error, 'Failed to save split test', {
  311. splitTest: JSON.stringify(splitTest),
  312. })
  313. }
  314. }
  315. module.exports = {
  316. getSplitTest,
  317. getSplitTests,
  318. createSplitTest,
  319. updateSplitTestConfig,
  320. updateSplitTestInfo,
  321. updateSplitTestBadgeInfo,
  322. switchToNextPhase,
  323. revertToPreviousVersion,
  324. archive,
  325. }