ChannelManager.test.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. import { vi, expect, describe, beforeEach, it } from 'vitest'
  2. import sinon from 'sinon'
  3. const modulePath = '../../../app/js/ChannelManager.js'
  4. describe('ChannelManager', function () {
  5. beforeEach(async function (ctx) {
  6. ctx.rclient = {}
  7. ctx.other_rclient = {}
  8. vi.doMock('@overleaf/settings', () => ({
  9. default: (ctx.settings = {}),
  10. }))
  11. vi.doMock('@overleaf/metrics', () => ({
  12. default: (ctx.metrics = {
  13. inc: sinon.stub(),
  14. summary: sinon.stub(),
  15. }),
  16. }))
  17. ctx.ChannelManager = (await import(modulePath)).default
  18. })
  19. describe('subscribe', function () {
  20. describe('when there is no existing subscription for this redis client', function () {
  21. beforeEach(async function (ctx) {
  22. await new Promise(resolve => {
  23. ctx.rclient.subscribe = sinon.stub().resolves()
  24. ctx.ChannelManager.subscribe(
  25. ctx.rclient,
  26. 'applied-ops',
  27. '1234567890abcdef'
  28. )
  29. setTimeout(resolve)
  30. })
  31. })
  32. it('should subscribe to the redis channel', function (ctx) {
  33. ctx.rclient.subscribe
  34. .calledWithExactly('applied-ops:1234567890abcdef')
  35. .should.equal(true)
  36. })
  37. })
  38. describe('when there is an existing subscription for this redis client', function () {
  39. beforeEach(async function (ctx) {
  40. await new Promise(resolve => {
  41. ctx.rclient.subscribe = sinon.stub().resolves()
  42. ctx.ChannelManager.subscribe(
  43. ctx.rclient,
  44. 'applied-ops',
  45. '1234567890abcdef'
  46. )
  47. ctx.ChannelManager.subscribe(
  48. ctx.rclient,
  49. 'applied-ops',
  50. '1234567890abcdef'
  51. )
  52. setTimeout(resolve)
  53. })
  54. })
  55. it('should subscribe to the redis channel again', function (ctx) {
  56. ctx.rclient.subscribe.callCount.should.equal(2)
  57. })
  58. })
  59. describe('when subscribe errors', function () {
  60. beforeEach(async function (ctx) {
  61. await new Promise((resolve, reject) => {
  62. ctx.rclient.subscribe = sinon
  63. .stub()
  64. .onFirstCall()
  65. .rejects(new Error('some redis error'))
  66. .onSecondCall()
  67. .resolves()
  68. const p = ctx.ChannelManager.subscribe(
  69. ctx.rclient,
  70. 'applied-ops',
  71. '1234567890abcdef'
  72. )
  73. p.then(() =>
  74. reject(new Error('should not subscribe but fail'))
  75. ).catch(err => {
  76. err.message.should.equal('failed to subscribe to channel')
  77. err.cause.message.should.equal('some redis error')
  78. ctx.ChannelManager.getClientMapEntry(ctx.rclient)
  79. .has('applied-ops:1234567890abcdef')
  80. .should.equal(false)
  81. ctx.ChannelManager.subscribe(
  82. ctx.rclient,
  83. 'applied-ops',
  84. '1234567890abcdef'
  85. )
  86. // subscribe is wrapped in Promise, delay other assertions
  87. setTimeout(resolve)
  88. })
  89. })
  90. })
  91. it('should have recorded the error', function (ctx) {
  92. expect(
  93. ctx.metrics.inc.calledWithExactly('subscribe.failed.applied-ops')
  94. ).to.equal(true)
  95. })
  96. it('should subscribe again', function (ctx) {
  97. ctx.rclient.subscribe.callCount.should.equal(2)
  98. })
  99. it('should cleanup', function (ctx) {
  100. ctx.ChannelManager.getClientMapEntry(ctx.rclient)
  101. .has('applied-ops:1234567890abcdef')
  102. .should.equal(false)
  103. })
  104. })
  105. describe('when subscribe errors and the clientChannelMap entry was replaced', function () {
  106. beforeEach(async function (ctx) {
  107. await new Promise((resolve, reject) => {
  108. ctx.rclient.subscribe = sinon
  109. .stub()
  110. .onFirstCall()
  111. .rejects(new Error('some redis error'))
  112. .onSecondCall()
  113. .resolves()
  114. ctx.first = ctx.ChannelManager.subscribe(
  115. ctx.rclient,
  116. 'applied-ops',
  117. '1234567890abcdef'
  118. )
  119. // ignore error
  120. ctx.first.catch(() => {})
  121. expect(
  122. ctx.ChannelManager.getClientMapEntry(ctx.rclient).get(
  123. 'applied-ops:1234567890abcdef'
  124. )
  125. ).to.equal(ctx.first)
  126. ctx.rclient.unsubscribe = sinon.stub().resolves()
  127. ctx.ChannelManager.unsubscribe(
  128. ctx.rclient,
  129. 'applied-ops',
  130. '1234567890abcdef'
  131. )
  132. ctx.second = ctx.ChannelManager.subscribe(
  133. ctx.rclient,
  134. 'applied-ops',
  135. '1234567890abcdef'
  136. )
  137. // should get replaced immediately
  138. expect(
  139. ctx.ChannelManager.getClientMapEntry(ctx.rclient).get(
  140. 'applied-ops:1234567890abcdef'
  141. )
  142. ).to.equal(ctx.second)
  143. // let the first subscribe error -> unsubscribe -> subscribe
  144. setTimeout(resolve)
  145. })
  146. })
  147. it('should cleanup the second subscribePromise', function (ctx) {
  148. expect(
  149. ctx.ChannelManager.getClientMapEntry(ctx.rclient).has(
  150. 'applied-ops:1234567890abcdef'
  151. )
  152. ).to.equal(false)
  153. })
  154. })
  155. describe('when there is an existing subscription for another redis client but not this one', function () {
  156. beforeEach(async function (ctx) {
  157. await new Promise(resolve => {
  158. ctx.other_rclient.subscribe = sinon.stub().resolves()
  159. ctx.ChannelManager.subscribe(
  160. ctx.other_rclient,
  161. 'applied-ops',
  162. '1234567890abcdef'
  163. )
  164. ctx.rclient.subscribe = sinon.stub().resolves() // discard the original stub
  165. ctx.ChannelManager.subscribe(
  166. ctx.rclient,
  167. 'applied-ops',
  168. '1234567890abcdef'
  169. )
  170. setTimeout(resolve)
  171. })
  172. })
  173. it('should subscribe to the redis channel on this redis client', function (ctx) {
  174. ctx.rclient.subscribe
  175. .calledWithExactly('applied-ops:1234567890abcdef')
  176. .should.equal(true)
  177. })
  178. })
  179. })
  180. describe('unsubscribe', function () {
  181. describe('when there is no existing subscription for this redis client', function () {
  182. beforeEach(async function (ctx) {
  183. await new Promise(resolve => {
  184. ctx.rclient.unsubscribe = sinon.stub().resolves()
  185. ctx.ChannelManager.unsubscribe(
  186. ctx.rclient,
  187. 'applied-ops',
  188. '1234567890abcdef'
  189. )
  190. setTimeout(resolve)
  191. })
  192. })
  193. it('should unsubscribe from the redis channel', function (ctx) {
  194. ctx.rclient.unsubscribe.called.should.equal(true)
  195. })
  196. })
  197. describe('when there is an existing subscription for this another redis client but not this one', function () {
  198. beforeEach(async function (ctx) {
  199. await new Promise(resolve => {
  200. ctx.other_rclient.subscribe = sinon.stub().resolves()
  201. ctx.rclient.unsubscribe = sinon.stub().resolves()
  202. ctx.ChannelManager.subscribe(
  203. ctx.other_rclient,
  204. 'applied-ops',
  205. '1234567890abcdef'
  206. )
  207. ctx.ChannelManager.unsubscribe(
  208. ctx.rclient,
  209. 'applied-ops',
  210. '1234567890abcdef'
  211. )
  212. setTimeout(resolve)
  213. })
  214. })
  215. it('should still unsubscribe from the redis channel on this client', function (ctx) {
  216. ctx.rclient.unsubscribe.called.should.equal(true)
  217. })
  218. })
  219. describe('when unsubscribe errors and completes', function () {
  220. beforeEach(async function (ctx) {
  221. await new Promise((resolve, reject) => {
  222. ctx.rclient.subscribe = sinon.stub().resolves()
  223. ctx.ChannelManager.subscribe(
  224. ctx.rclient,
  225. 'applied-ops',
  226. '1234567890abcdef'
  227. )
  228. ctx.rclient.unsubscribe = sinon
  229. .stub()
  230. .rejects(new Error('some redis error'))
  231. ctx.ChannelManager.unsubscribe(
  232. ctx.rclient,
  233. 'applied-ops',
  234. '1234567890abcdef'
  235. )
  236. setTimeout(resolve)
  237. return null
  238. })
  239. })
  240. it('should have cleaned up', function (ctx) {
  241. ctx.ChannelManager.getClientMapEntry(ctx.rclient)
  242. .has('applied-ops:1234567890abcdef')
  243. .should.equal(false)
  244. })
  245. it('should not error out when subscribing again', async function (ctx) {
  246. await new Promise((resolve, reject) => {
  247. const p = ctx.ChannelManager.subscribe(
  248. ctx.rclient,
  249. 'applied-ops',
  250. '1234567890abcdef'
  251. )
  252. p.then(() => resolve()).catch(reject)
  253. })
  254. })
  255. })
  256. describe('when unsubscribe errors and another client subscribes at the same time', function () {
  257. beforeEach(async function (ctx) {
  258. await new Promise((resolve, reject) => {
  259. ctx.rclient.subscribe = sinon.stub().resolves()
  260. ctx.ChannelManager.subscribe(
  261. ctx.rclient,
  262. 'applied-ops',
  263. '1234567890abcdef'
  264. )
  265. let rejectSubscribe
  266. ctx.rclient.unsubscribe = () =>
  267. new Promise((resolve, reject) => (rejectSubscribe = reject))
  268. ctx.ChannelManager.unsubscribe(
  269. ctx.rclient,
  270. 'applied-ops',
  271. '1234567890abcdef'
  272. )
  273. setTimeout(() => {
  274. // delay, actualUnsubscribe should not see the new subscribe request
  275. ctx.ChannelManager.subscribe(
  276. ctx.rclient,
  277. 'applied-ops',
  278. '1234567890abcdef'
  279. )
  280. .then(() => setTimeout(resolve))
  281. .catch(reject)
  282. setTimeout(() =>
  283. // delay, rejectSubscribe is not defined immediately
  284. rejectSubscribe(new Error('redis error'))
  285. )
  286. })
  287. })
  288. })
  289. it('should have recorded the error', function (ctx) {
  290. expect(
  291. ctx.metrics.inc.calledWithExactly('unsubscribe.failed.applied-ops')
  292. ).to.equal(true)
  293. })
  294. it('should have subscribed', function (ctx) {
  295. ctx.rclient.subscribe.called.should.equal(true)
  296. })
  297. it('should have discarded the finished Promise', function (ctx) {
  298. ctx.ChannelManager.getClientMapEntry(ctx.rclient)
  299. .has('applied-ops:1234567890abcdef')
  300. .should.equal(false)
  301. })
  302. })
  303. describe('when there is an existing subscription for this redis client', function () {
  304. beforeEach(async function (ctx) {
  305. await new Promise((resolve, reject) => {
  306. ctx.rclient.subscribe = sinon.stub().resolves()
  307. ctx.rclient.unsubscribe = sinon.stub().resolves()
  308. ctx.ChannelManager.subscribe(
  309. ctx.rclient,
  310. 'applied-ops',
  311. '1234567890abcdef'
  312. )
  313. ctx.ChannelManager.unsubscribe(
  314. ctx.rclient,
  315. 'applied-ops',
  316. '1234567890abcdef'
  317. )
  318. setTimeout(resolve)
  319. })
  320. })
  321. it('should unsubscribe from the redis channel', function (ctx) {
  322. ctx.rclient.unsubscribe
  323. .calledWithExactly('applied-ops:1234567890abcdef')
  324. .should.equal(true)
  325. })
  326. })
  327. })
  328. describe('publish', function () {
  329. describe("when the channel is 'all'", function () {
  330. beforeEach(function (ctx) {
  331. ctx.rclient.publish = sinon.stub()
  332. ctx.ChannelManager.publish(
  333. ctx.rclient,
  334. 'applied-ops',
  335. 'all',
  336. 'random-message'
  337. )
  338. })
  339. it('should publish on the base channel', function (ctx) {
  340. ctx.rclient.publish
  341. .calledWithExactly('applied-ops', 'random-message')
  342. .should.equal(true)
  343. })
  344. })
  345. describe('when the channel has an specific id', function () {
  346. describe('when the individual channel setting is false', function () {
  347. beforeEach(function (ctx) {
  348. ctx.rclient.publish = sinon.stub()
  349. ctx.settings.publishOnIndividualChannels = false
  350. ctx.ChannelManager.publish(
  351. ctx.rclient,
  352. 'applied-ops',
  353. '1234567890abcdef',
  354. 'random-message'
  355. )
  356. })
  357. it('should publish on the per-id channel', function (ctx) {
  358. ctx.rclient.publish
  359. .calledWithExactly('applied-ops', 'random-message')
  360. .should.equal(true)
  361. ctx.rclient.publish.calledOnce.should.equal(true)
  362. })
  363. })
  364. describe('when the individual channel setting is true', function () {
  365. beforeEach(function (ctx) {
  366. ctx.rclient.publish = sinon.stub()
  367. ctx.settings.publishOnIndividualChannels = true
  368. ctx.ChannelManager.publish(
  369. ctx.rclient,
  370. 'applied-ops',
  371. '1234567890abcdef',
  372. 'random-message'
  373. )
  374. })
  375. it('should publish on the per-id channel', function (ctx) {
  376. ctx.rclient.publish
  377. .calledWithExactly('applied-ops:1234567890abcdef', 'random-message')
  378. .should.equal(true)
  379. ctx.rclient.publish.calledOnce.should.equal(true)
  380. })
  381. })
  382. })
  383. describe('metrics', function () {
  384. beforeEach(function (ctx) {
  385. ctx.rclient.publish = sinon.stub()
  386. ctx.ChannelManager.publish(
  387. ctx.rclient,
  388. 'applied-ops',
  389. 'all',
  390. 'random-message'
  391. )
  392. })
  393. it('should track the payload size', function (ctx) {
  394. ctx.metrics.summary
  395. .calledWithExactly(
  396. 'redis.publish.applied-ops',
  397. 'random-message'.length
  398. )
  399. .should.equal(true)
  400. })
  401. })
  402. })
  403. })