SplitTestManager.js 8.0 KB

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