SplitTestManager.js 14 KB

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