RedisBackendTests.coffee 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. sinon = require('sinon')
  2. chai = require('chai')
  3. should = chai.should()
  4. modulePath = "../../../../app/js/RedisBackend.js"
  5. SandboxedModule = require('sandboxed-module')
  6. RedisKeyBuilder = require "../../../../app/js/RedisKeyBuilder"
  7. describe "RedisBackend", ->
  8. beforeEach ->
  9. @Settings =
  10. redis:
  11. documentupdater: [{
  12. primary: true
  13. port: "6379"
  14. host: "localhost"
  15. password: "single-password"
  16. key_schema:
  17. blockingKey: ({doc_id}) -> "Blocking:#{doc_id}"
  18. docLines: ({doc_id}) -> "doclines:#{doc_id}"
  19. docOps: ({doc_id}) -> "DocOps:#{doc_id}"
  20. docVersion: ({doc_id}) -> "DocVersion:#{doc_id}"
  21. projectKey: ({doc_id}) -> "ProjectId:#{doc_id}"
  22. pendingUpdates: ({doc_id}) -> "PendingUpdates:#{doc_id}"
  23. docsInProject: ({project_id}) -> "DocsIn:#{project_id}"
  24. }, {
  25. cluster: [{
  26. port: "7000"
  27. host: "localhost"
  28. }]
  29. password: "cluster-password"
  30. key_schema:
  31. blockingKey: ({doc_id}) -> "Blocking:{#{doc_id}}"
  32. docLines: ({doc_id}) -> "doclines:{#{doc_id}}"
  33. docOps: ({doc_id}) -> "DocOps:{#{doc_id}}"
  34. docVersion: ({doc_id}) -> "DocVersion:{#{doc_id}}"
  35. projectKey: ({doc_id}) -> "ProjectId:{#{doc_id}}"
  36. pendingUpdates: ({doc_id}) -> "PendingUpdates:{#{doc_id}}"
  37. docsInProject: ({project_id}) -> "DocsIn:{#{project_id}}"
  38. }]
  39. test_context = @
  40. class Cluster
  41. constructor: (@config) ->
  42. test_context.rclient_ioredis = @
  43. nodes: sinon.stub()
  44. @timer = timer = sinon.stub()
  45. class Timer
  46. constructor: (args...) -> timer(args...)
  47. done: () ->
  48. @RedisBackend = SandboxedModule.require modulePath, requires:
  49. "settings-sharelatex": @Settings
  50. "logger-sharelatex": @logger = { error: sinon.stub(), log: sinon.stub(), warn: sinon.stub() }
  51. "redis-sharelatex": @redis =
  52. createClient: sinon.stub().returns @rclient_redis = {}
  53. activeHealthCheck: sinon.stub()
  54. "ioredis": @ioredis =
  55. Cluster: Cluster
  56. "metrics-sharelatex":
  57. @Metrics =
  58. inc: sinon.stub()
  59. Timer: Timer
  60. @client = @RedisBackend.createClient()
  61. @doc_id = "mock-doc-id"
  62. @project_id = "mock-project-id"
  63. it "should create a redis client", ->
  64. @redis.createClient
  65. .calledWith({
  66. port: "6379"
  67. host: "localhost"
  68. password: "single-password"
  69. })
  70. .should.equal true
  71. it "should create an ioredis cluster client", ->
  72. @rclient_ioredis.config.should.deep.equal [{
  73. port: "7000"
  74. host: "localhost"
  75. }]
  76. describe "individual commands", ->
  77. describe "with the same results", ->
  78. beforeEach (done) ->
  79. @content = "bar"
  80. @rclient_redis.get = sinon.stub()
  81. @rclient_redis.get.withArgs("doclines:#{@doc_id}").yields(null, @content)
  82. @rclient_ioredis.get = sinon.stub()
  83. @rclient_ioredis.get.withArgs("doclines:{#{@doc_id}}").yields(null, @content)
  84. @client.get RedisKeyBuilder.docLines({doc_id: @doc_id}), (error, @result) =>
  85. setTimeout () -> # Let all background requests complete
  86. done(error)
  87. it "should return the result", ->
  88. @result.should.equal @content
  89. it "should have called the redis client with the appropriate key", ->
  90. @rclient_redis.get
  91. .calledWith("doclines:#{@doc_id}")
  92. .should.equal true
  93. it "should have called the ioredis cluster client with the appropriate key", ->
  94. @rclient_ioredis.get
  95. .calledWith("doclines:{#{@doc_id}}")
  96. .should.equal true
  97. it "should send a metric", ->
  98. @Metrics.inc
  99. .calledWith("backend-match")
  100. .should.equal true
  101. it "should time the commands", ->
  102. @timer
  103. .calledWith("redis.ioredis.get")
  104. .should.equal true
  105. @timer
  106. .calledWith("redis.noderedis.get")
  107. .should.equal true
  108. describe "with different results", ->
  109. beforeEach (done) ->
  110. @rclient_redis.get = sinon.stub()
  111. @rclient_redis.get.withArgs("doclines:#{@doc_id}").yields(null, "primary-result")
  112. @rclient_ioredis.get = sinon.stub()
  113. @rclient_ioredis.get.withArgs("doclines:{#{@doc_id}}").yields(null, "secondary-result")
  114. @client.get RedisKeyBuilder.docLines({doc_id: @doc_id}), (error, @result) =>
  115. setTimeout () -> # Let all background requests complete
  116. done(error)
  117. it "should return the primary result", ->
  118. @result.should.equal "primary-result"
  119. it "should send a metric", ->
  120. @Metrics.inc
  121. .calledWith("backend-conflict")
  122. .should.equal true
  123. describe "with differently ordered results from smembers", ->
  124. beforeEach (done) ->
  125. @rclient_redis.smembers = sinon.stub()
  126. @rclient_redis.smembers.withArgs("DocsIn:#{@project_id}").yields(null, ["one", "two"])
  127. @rclient_ioredis.smembers = sinon.stub()
  128. @rclient_ioredis.smembers.withArgs("DocsIn:{#{@project_id}}").yields(null, ["two", "one"])
  129. @client.smembers RedisKeyBuilder.docsInProject({project_id: @project_id}), (error, @result) =>
  130. setTimeout () -> # Let all background requests complete
  131. done(error)
  132. it "should return the primary result", ->
  133. @result.should.deep.equal ["one", "two"]
  134. it "should send a metric indicating a match", ->
  135. @Metrics.inc
  136. .calledWith("backend-match")
  137. .should.equal true
  138. describe "when the secondary errors", ->
  139. beforeEach (done) ->
  140. @rclient_redis.get = sinon.stub()
  141. @rclient_redis.get.withArgs("doclines:#{@doc_id}").yields(null, "primary-result")
  142. @rclient_ioredis.get = sinon.stub()
  143. @rclient_ioredis.get.withArgs("doclines:{#{@doc_id}}").yields(@error = new Error("oops"))
  144. @client.get RedisKeyBuilder.docLines({doc_id: @doc_id}), (error, @result) =>
  145. setTimeout () -> # Let all background requests complete
  146. done(error)
  147. it "should return the primary result", ->
  148. @result.should.equal "primary-result"
  149. it "should log out the secondary error", ->
  150. @logger.error
  151. .calledWith({
  152. err: @error
  153. }, "error in redis backend")
  154. .should.equal true
  155. describe "when the primary errors", ->
  156. beforeEach (done) ->
  157. @rclient_redis.get = sinon.stub()
  158. @rclient_redis.get.withArgs("doclines:#{@doc_id}").yields(@error = new Error("oops"))
  159. @rclient_ioredis.get = sinon.stub()
  160. @rclient_ioredis.get.withArgs("doclines:{#{@doc_id}}").yields(null, "secondary-result")
  161. @client.get RedisKeyBuilder.docLines({doc_id: @doc_id}), (@returned_error, @result) =>
  162. setTimeout () -> # Let all background requests complete
  163. done()
  164. it "should return the error", ->
  165. @returned_error.should.equal @error
  166. it "should log out the error", ->
  167. @logger.error
  168. .calledWith({
  169. err: @error
  170. }, "error in redis backend")
  171. .should.equal true
  172. describe "when the command has the key in a non-zero argument index", ->
  173. beforeEach (done) ->
  174. @script = "mock-script"
  175. @key_count = 1
  176. @value = "mock-value"
  177. @rclient_redis.eval = sinon.stub()
  178. @rclient_redis.eval.withArgs(@script, @key_count, "Blocking:#{@doc_id}", @value).yields(null)
  179. @rclient_ioredis.eval = sinon.stub()
  180. @rclient_ioredis.eval.withArgs(@script, @key_count, "Blocking:{#{@doc_id}}", @value).yields(null, @content)
  181. @client.eval @script, @key_count, RedisKeyBuilder.blockingKey({doc_id: @doc_id}), @value, (error) =>
  182. setTimeout () -> # Let all background requests complete
  183. done(error)
  184. it "should have called the redis client with the appropriate key", ->
  185. @rclient_redis.eval
  186. .calledWith(@script, @key_count, "Blocking:#{@doc_id}", @value)
  187. .should.equal true
  188. it "should have called the ioredis cluster client with the appropriate key", ->
  189. @rclient_ioredis.eval
  190. .calledWith(@script, @key_count, "Blocking:{#{@doc_id}}", @value)
  191. .should.equal true
  192. describe "when the secondary takes longer than SECONDARY_TIMEOUT", ->
  193. beforeEach (done) ->
  194. @client.SECONDARY_TIMEOUT = 10
  195. @content = "bar"
  196. @rclient_redis.get = (key, cb) =>
  197. key.should.equal "doclines:#{@doc_id}"
  198. setTimeout () =>
  199. cb(null, @content)
  200. , @client.SECONDARY_TIMEOUT * 3 # If the secondary errors first, don't affect the primary result
  201. @rclient_ioredis.get = (key, cb) =>
  202. key.should.equal "doclines:{#{@doc_id}}"
  203. setTimeout () =>
  204. cb(null, @content)
  205. , @client.SECONDARY_TIMEOUT * 2
  206. @client.get RedisKeyBuilder.docLines({doc_id: @doc_id}), (error, @result) =>
  207. done(error)
  208. it "should log out an error for the backend", ->
  209. @logger.error
  210. .calledWith({err: new Error("backend timed out")}, "backend timed out")
  211. .should.equal true
  212. it "should return the primary result", ->
  213. @result.should.equal @content
  214. describe "when the primary takes longer than SECONDARY_TIMEOUT", ->
  215. beforeEach (done) ->
  216. @client.SECONDARY_TIMEOUT = 10
  217. @content = "bar"
  218. @rclient_ioredis.get = sinon.stub()
  219. @rclient_ioredis.get.withArgs("doclines:{#{@doc_id}}").yields(null, @content)
  220. @rclient_redis.get = (key, cb) =>
  221. key.should.equal "doclines:#{@doc_id}"
  222. setTimeout () =>
  223. cb(null, @content)
  224. , @client.SECONDARY_TIMEOUT * 2
  225. @client.get RedisKeyBuilder.docLines({doc_id: @doc_id}), (error, @result) =>
  226. done(error)
  227. it "should not consider this an error", ->
  228. @logger.error
  229. .called
  230. .should.equal false
  231. describe "multi commands", ->
  232. beforeEach ->
  233. # We will test with:
  234. # rclient.multi()
  235. # .get("doclines:foo")
  236. # .get("DocVersion:foo")
  237. # .exec (...) ->
  238. @doclines = "mock-doclines"
  239. @version = "42"
  240. @rclient_redis.multi = sinon.stub().returns @rclient_redis
  241. @rclient_ioredis.multi = sinon.stub().returns @rclient_ioredis
  242. describe "with the same results", ->
  243. beforeEach (done) ->
  244. @rclient_redis.get = sinon.stub()
  245. @rclient_redis.exec = sinon.stub().yields(null, [@doclines, @version])
  246. @rclient_ioredis.get = sinon.stub()
  247. @rclient_ioredis.exec = sinon.stub().yields(null, [ [null, @doclines], [null, @version] ])
  248. multi = @client.multi()
  249. multi.get RedisKeyBuilder.docLines({doc_id: @doc_id})
  250. multi.get RedisKeyBuilder.docVersion({doc_id: @doc_id})
  251. multi.exec (error, @result) =>
  252. setTimeout () ->
  253. done(error)
  254. it "should return the result", ->
  255. @result.should.deep.equal [@doclines, @version]
  256. it "should have called the redis client with the appropriate keys", ->
  257. @rclient_redis.get
  258. .calledWith("doclines:#{@doc_id}")
  259. .should.equal true
  260. @rclient_redis.get
  261. .calledWith("DocVersion:#{@doc_id}")
  262. .should.equal true
  263. @rclient_ioredis.exec
  264. .called
  265. .should.equal true
  266. it "should have called the ioredis cluster client with the appropriate keys", ->
  267. @rclient_ioredis.get
  268. .calledWith("doclines:{#{@doc_id}}")
  269. .should.equal true
  270. @rclient_ioredis.get
  271. .calledWith("DocVersion:{#{@doc_id}}")
  272. .should.equal true
  273. @rclient_ioredis.exec
  274. .called
  275. .should.equal true
  276. it "should send a metric", ->
  277. @Metrics.inc
  278. .calledWith("backend-match")
  279. .should.equal true
  280. it "should time the exec", ->
  281. @timer
  282. .calledWith("redis.ioredis.exec")
  283. .should.equal true
  284. @timer
  285. .calledWith("redis.noderedis.exec")
  286. .should.equal true
  287. describe "with different results", ->
  288. beforeEach (done) ->
  289. @rclient_redis.get = sinon.stub()
  290. @rclient_redis.exec = sinon.stub().yields(null, [@doclines, @version])
  291. @rclient_ioredis.get = sinon.stub()
  292. @rclient_ioredis.exec = sinon.stub().yields(null, [ [null, "different-doc-lines"], [null, @version] ])
  293. multi = @client.multi()
  294. multi.get RedisKeyBuilder.docLines({doc_id: @doc_id})
  295. multi.get RedisKeyBuilder.docVersion({doc_id: @doc_id})
  296. multi.exec (error, @result) =>
  297. setTimeout () ->
  298. done(error)
  299. it "should return the primary result", ->
  300. @result.should.deep.equal [@doclines, @version]
  301. it "should send a metric", ->
  302. @Metrics.inc
  303. .calledWith("backend-conflict")
  304. .should.equal true
  305. describe "when the secondary errors", ->
  306. beforeEach (done) ->
  307. @rclient_redis.get = sinon.stub()
  308. @rclient_redis.exec = sinon.stub().yields(null, [@doclines, @version])
  309. @rclient_ioredis.get = sinon.stub()
  310. @rclient_ioredis.exec = sinon.stub().yields(@error = new Error("oops"))
  311. multi = @client.multi()
  312. multi.get RedisKeyBuilder.docLines({doc_id: @doc_id})
  313. multi.get RedisKeyBuilder.docVersion({doc_id: @doc_id})
  314. multi.exec (error, @result) =>
  315. setTimeout () ->
  316. done(error)
  317. it "should return the primary result", ->
  318. @result.should.deep.equal [@doclines, @version]
  319. it "should log out the secondary error", ->
  320. @logger.error
  321. .calledWith({
  322. err: @error
  323. }, "error in redis backend")
  324. .should.equal true
  325. describe "when the secondary errors", ->
  326. beforeEach (done) ->
  327. @rclient_redis.get = sinon.stub()
  328. @rclient_redis.exec = sinon.stub().yields(@error = new Error("oops"))
  329. @rclient_ioredis.get = sinon.stub()
  330. @rclient_ioredis.exec = sinon.stub().yields([ [null, @doclines], [null, @version] ])
  331. multi = @client.multi()
  332. multi.get RedisKeyBuilder.docLines({doc_id: @doc_id})
  333. multi.get RedisKeyBuilder.docVersion({doc_id: @doc_id})
  334. multi.exec (@returned_error) =>
  335. setTimeout () -> done()
  336. it "should return the error", ->
  337. @returned_error.should.equal @error
  338. it "should log out the error", ->
  339. @logger.error
  340. .calledWith({
  341. err: @error
  342. }, "error in redis backend")
  343. .should.equal true
  344. describe "when the secondary takes longer than SECONDARY_TIMEOUT", ->
  345. beforeEach (done) ->
  346. @rclient_redis.get = sinon.stub()
  347. @rclient_redis.exec = (cb) =>
  348. setTimeout () =>
  349. cb(null, [@doclines, @version])
  350. , 30 # If secondary errors first, don't affect the primary result
  351. @rclient_ioredis.get = sinon.stub()
  352. @rclient_ioredis.exec = (cb) =>
  353. setTimeout () =>
  354. cb(null, [ [null, @doclines], [null, @version] ])
  355. , 20
  356. multi = @client.multi()
  357. multi.SECONDARY_TIMEOUT = 10
  358. multi.get RedisKeyBuilder.docLines({doc_id: @doc_id})
  359. multi.get RedisKeyBuilder.docVersion({doc_id: @doc_id})
  360. multi.exec (error, @result) =>
  361. done(error)
  362. it "should log out an error for the backend", ->
  363. @logger.error
  364. .calledWith({err: new Error("backend timed out")}, "backend timed out")
  365. .should.equal true
  366. it "should return the primary result", ->
  367. @result.should.deep.equal [@doclines, @version]
  368. describe "when the primary takes longer than SECONDARY_TIMEOUT", ->
  369. beforeEach (done) ->
  370. @rclient_redis.get = sinon.stub()
  371. @rclient_redis.exec = (cb) =>
  372. setTimeout () =>
  373. cb(null, [@doclines, @version])
  374. , 20
  375. @rclient_ioredis.get = sinon.stub()
  376. @rclient_ioredis.exec = sinon.stub().yields(null, [ [null, @doclines], [null, @version] ])
  377. multi = @client.multi()
  378. multi.SECONDARY_TIMEOUT = 10
  379. multi.get RedisKeyBuilder.docLines({doc_id: @doc_id})
  380. multi.get RedisKeyBuilder.docVersion({doc_id: @doc_id})
  381. multi.exec (error, @result) =>
  382. done(error)
  383. it "should not consider this an error", ->
  384. @logger.error
  385. .called
  386. .should.equal false
  387. describe "_healthCheckNodeRedisClient", ->
  388. beforeEach ->
  389. @redis.activeHealthCheckRedis = sinon.stub().returns @healthCheck = {
  390. isAlive: sinon.stub()
  391. }
  392. describe "successfully", ->
  393. beforeEach (done) ->
  394. @healthCheck.isAlive.returns true
  395. @redis_client = {}
  396. @client._healthCheckNodeRedisClient(@redis_client, done)
  397. it "should check the status of the node redis client", ->
  398. @healthCheck.isAlive.called.should.equal true
  399. it "should only create one health check when called multiple times", (done) ->
  400. @client._healthCheckNodeRedisClient @redis_client, () =>
  401. @redis.activeHealthCheckRedis.calledOnce.should.equal true
  402. @healthCheck.isAlive.calledTwice.should.equal true
  403. done()
  404. describe "when failing", ->
  405. beforeEach ->
  406. @healthCheck.isAlive.returns false
  407. @redis_client = {}
  408. it "should return an error", (done) ->
  409. @client._healthCheckNodeRedisClient @redis_client, (error) ->
  410. error.message.should.equal "node-redis client failed health check"
  411. done()
  412. describe "_healthCheckClusterClient", ->
  413. beforeEach ->
  414. @client.HEARTBEAT_TIMEOUT = 10
  415. @nodes = [{
  416. options: key: "node-0"
  417. stream: destroy: sinon.stub()
  418. }, {
  419. options: key: "node-1"
  420. stream: destroy: sinon.stub()
  421. }]
  422. @rclient_ioredis.nodes = sinon.stub().returns(@nodes)
  423. describe "when both clients are successful", ->
  424. beforeEach (done) ->
  425. @nodes[0].ping = sinon.stub().yields()
  426. @nodes[1].ping = sinon.stub().yields()
  427. @client._healthCheckClusterClient({ rclient: @rclient_ioredis }, done)
  428. it "should get all cluster nodes", ->
  429. @rclient_ioredis.nodes
  430. .calledWith("all")
  431. .should.equal true
  432. it "should ping each cluster node", ->
  433. for node in @nodes
  434. node.ping.called.should.equal true
  435. describe "when ping fails to a node", ->
  436. beforeEach ->
  437. @nodes[0].ping = (cb) -> cb()
  438. @nodes[1].ping = (cb) -> # Just hang
  439. it "should return an error", (done) ->
  440. @client._healthCheckClusterClient { rclient: @rclient_ioredis }, (error) ->
  441. error.message.should.equal "ioredis node ping check timed out"
  442. done()