RedisManager.coffee 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. Settings = require('settings-sharelatex')
  2. rclient = require("redis-sharelatex").createClient(Settings.redis.documentupdater)
  3. _ = require('underscore')
  4. logger = require('logger-sharelatex')
  5. metrics = require('./Metrics')
  6. Errors = require "./Errors"
  7. crypto = require "crypto"
  8. ProjectHistoryRedisManager = require "./ProjectHistoryRedisManager"
  9. # Sometimes Redis calls take an unexpectedly long time. We have to be
  10. # quick with Redis calls because we're holding a lock that expires
  11. # after 30 seconds. We can't let any errors in the rest of the stack
  12. # hold us up, and need to bail out quickly if there is a problem.
  13. MAX_REDIS_REQUEST_LENGTH = 5000 # 5 seconds
  14. # Make times easy to read
  15. minutes = 60 # seconds for Redis expire
  16. # LUA script to write document and return hash
  17. # arguments: docLinesKey docLines
  18. setScript = """
  19. redis.call('set', KEYS[1], ARGV[1])
  20. return redis.sha1hex(ARGV[1])
  21. """
  22. logHashErrors = Settings.documentupdater?.logHashErrors
  23. logHashReadErrors = logHashErrors?.read
  24. logHashWriteErrors = logHashErrors?.write
  25. MEGABYTES = 1024 * 1024
  26. MAX_RANGES_SIZE = 3 * MEGABYTES
  27. keys = Settings.redis.documentupdater.key_schema
  28. historyKeys = Settings.redis.history.key_schema
  29. module.exports = RedisManager =
  30. rclient: rclient
  31. putDocInMemory : (project_id, doc_id, docLines, version, ranges, pathname, _callback)->
  32. timer = new metrics.Timer("redis.put-doc")
  33. callback = (error) ->
  34. timer.done()
  35. _callback(error)
  36. docLines = JSON.stringify(docLines)
  37. if docLines.indexOf("\u0000") != -1
  38. error = new Error("null bytes found in doc lines")
  39. logger.error {err: error, doc_id: doc_id, docLines: docLines}, error.message
  40. return callback(error)
  41. docHash = RedisManager._computeHash(docLines)
  42. logger.log {project_id, doc_id, version, docHash, pathname}, "putting doc in redis"
  43. RedisManager._serializeRanges ranges, (error, ranges) ->
  44. if error?
  45. logger.error {err: error, doc_id, project_id}, error.message
  46. return callback(error)
  47. multi = rclient.multi()
  48. multi.eval setScript, 1, keys.docLines(doc_id:doc_id), docLines
  49. multi.set keys.projectKey({doc_id:doc_id}), project_id
  50. multi.set keys.docVersion(doc_id:doc_id), version
  51. multi.set keys.docHash(doc_id:doc_id), docHash
  52. if ranges?
  53. multi.set keys.ranges(doc_id:doc_id), ranges
  54. else
  55. multi.del keys.ranges(doc_id:doc_id)
  56. multi.set keys.pathname(doc_id:doc_id), pathname
  57. multi.exec (error, result) ->
  58. return callback(error) if error?
  59. # check the hash computed on the redis server
  60. writeHash = result?[0]
  61. if logHashWriteErrors and writeHash? and writeHash isnt docHash
  62. logger.error project_id: project_id, doc_id: doc_id, writeHash: writeHash, origHash: docHash, docLines:docLines, "hash mismatch on putDocInMemory"
  63. # update docsInProject set
  64. rclient.sadd keys.docsInProject(project_id:project_id), doc_id, callback
  65. removeDocFromMemory : (project_id, doc_id, _callback)->
  66. logger.log project_id:project_id, doc_id:doc_id, "removing doc from redis"
  67. callback = (err) ->
  68. if err?
  69. logger.err project_id:project_id, doc_id:doc_id, err:err, "error removing doc from redis"
  70. _callback(err)
  71. else
  72. logger.log project_id:project_id, doc_id:doc_id, "removed doc from redis"
  73. _callback()
  74. multi = rclient.multi()
  75. multi.del keys.docLines(doc_id:doc_id)
  76. multi.del keys.projectKey(doc_id:doc_id)
  77. multi.del keys.docVersion(doc_id:doc_id)
  78. multi.del keys.docHash(doc_id:doc_id)
  79. multi.del keys.ranges(doc_id:doc_id)
  80. multi.del keys.pathname(doc_id:doc_id)
  81. multi.del keys.unflushedTime(doc_id:doc_id)
  82. multi.exec (error) ->
  83. return callback(error) if error?
  84. multi = rclient.multi()
  85. multi.srem keys.docsInProject(project_id:project_id), doc_id
  86. multi.del keys.projectState(project_id:project_id)
  87. multi.exec callback
  88. checkOrSetProjectState: (project_id, newState, callback = (error, stateChanged) ->) ->
  89. multi = rclient.multi()
  90. multi.getset keys.projectState(project_id:project_id), newState
  91. multi.expire keys.projectState(project_id:project_id), 30 * minutes
  92. multi.exec (error, response) ->
  93. return callback(error) if error?
  94. logger.log project_id: project_id, newState:newState, oldState: response[0], "checking project state"
  95. callback(null, response[0] isnt newState)
  96. clearProjectState: (project_id, callback = (error) ->) ->
  97. rclient.del keys.projectState(project_id:project_id), callback
  98. getDoc : (project_id, doc_id, callback = (error, lines, version, ranges, pathname, unflushedTime) ->)->
  99. timer = new metrics.Timer("redis.get-doc")
  100. multi = rclient.multi()
  101. multi.get keys.docLines(doc_id:doc_id)
  102. multi.get keys.docVersion(doc_id:doc_id)
  103. multi.get keys.docHash(doc_id:doc_id)
  104. multi.get keys.projectKey(doc_id:doc_id)
  105. multi.get keys.ranges(doc_id:doc_id)
  106. multi.get keys.pathname(doc_id:doc_id)
  107. multi.get keys.unflushedTime(doc_id:doc_id)
  108. multi.exec (error, [docLines, version, storedHash, doc_project_id, ranges, pathname, unflushedTime])->
  109. timeSpan = timer.done()
  110. return callback(error) if error?
  111. # check if request took too long and bail out. only do this for
  112. # get, because it is the first call in each update, so if this
  113. # passes we'll assume others have a reasonable chance to succeed.
  114. if timeSpan > MAX_REDIS_REQUEST_LENGTH
  115. error = new Error("redis getDoc exceeded timeout")
  116. return callback(error)
  117. # check sha1 hash value if present
  118. if docLines? and storedHash?
  119. computedHash = RedisManager._computeHash(docLines)
  120. if logHashReadErrors and computedHash isnt storedHash
  121. logger.error project_id: project_id, doc_id: doc_id, doc_project_id: doc_project_id, computedHash: computedHash, storedHash: storedHash, docLines:docLines, "hash mismatch on retrieved document"
  122. try
  123. docLines = JSON.parse docLines
  124. ranges = RedisManager._deserializeRanges(ranges)
  125. catch e
  126. return callback(e)
  127. version = parseInt(version or 0, 10)
  128. # check doc is in requested project
  129. if doc_project_id? and doc_project_id isnt project_id
  130. logger.error project_id: project_id, doc_id: doc_id, doc_project_id: doc_project_id, "doc not in project"
  131. return callback(new Errors.NotFoundError("document not found"))
  132. # doc is not in redis, bail out
  133. if !docLines?
  134. return callback null, docLines, version, ranges, pathname, unflushedTime
  135. # doc should be in project set, check if missing (workaround for missing docs from putDoc)
  136. rclient.sadd keys.docsInProject(project_id:project_id), doc_id, (error, result) ->
  137. return callback(error) if error?
  138. if result isnt 0 # doc should already be in set
  139. logger.error project_id: project_id, doc_id: doc_id, doc_project_id: doc_project_id, "doc missing from docsInProject set"
  140. callback null, docLines, version, ranges, pathname, unflushedTime
  141. getDocVersion: (doc_id, callback = (error, version) ->) ->
  142. rclient.get keys.docVersion(doc_id: doc_id), (error, version) ->
  143. return callback(error) if error?
  144. version = parseInt(version, 10)
  145. callback null, version
  146. getDocLines: (doc_id, callback = (error, version) ->) ->
  147. rclient.get keys.docLines(doc_id: doc_id), (error, docLines) ->
  148. return callback(error) if error?
  149. callback null, docLines
  150. getPreviousDocOps: (doc_id, start, end, callback = (error, jsonOps) ->) ->
  151. timer = new metrics.Timer("redis.get-prev-docops")
  152. rclient.llen keys.docOps(doc_id: doc_id), (error, length) ->
  153. return callback(error) if error?
  154. rclient.get keys.docVersion(doc_id: doc_id), (error, version) ->
  155. return callback(error) if error?
  156. version = parseInt(version, 10)
  157. first_version_in_redis = version - length
  158. if start < first_version_in_redis or end > version
  159. error = new Errors.OpRangeNotAvailableError("doc ops range is not loaded in redis")
  160. logger.warn {err: error, doc_id, length, version, start, end}, "doc ops range is not loaded in redis"
  161. return callback(error)
  162. start = start - first_version_in_redis
  163. if end > -1
  164. end = end - first_version_in_redis
  165. if isNaN(start) or isNaN(end)
  166. error = new Error("inconsistent version or lengths")
  167. logger.error {err: error, doc_id, length, version, start, end}, "inconsistent version or length"
  168. return callback(error)
  169. rclient.lrange keys.docOps(doc_id: doc_id), start, end, (error, jsonOps) ->
  170. return callback(error) if error?
  171. try
  172. ops = jsonOps.map (jsonOp) -> JSON.parse jsonOp
  173. catch e
  174. return callback(e)
  175. timeSpan = timer.done()
  176. if timeSpan > MAX_REDIS_REQUEST_LENGTH
  177. error = new Error("redis getPreviousDocOps exceeded timeout")
  178. return callback(error)
  179. callback null, ops
  180. DOC_OPS_TTL: 60 * minutes
  181. DOC_OPS_MAX_LENGTH: 100
  182. updateDocument : (project_id, doc_id, docLines, newVersion, appliedOps = [], ranges, callback = (error) ->)->
  183. RedisManager.getDocVersion doc_id, (error, currentVersion) ->
  184. return callback(error) if error?
  185. if currentVersion + appliedOps.length != newVersion
  186. error = new Error("Version mismatch. '#{doc_id}' is corrupted.")
  187. logger.error {err: error, doc_id, currentVersion, newVersion, opsLength: appliedOps.length}, "version mismatch"
  188. return callback(error)
  189. jsonOps = appliedOps.map (op) -> JSON.stringify op
  190. for op in jsonOps
  191. if op.indexOf("\u0000") != -1
  192. error = new Error("null bytes found in jsonOps")
  193. logger.error {err: error, doc_id: doc_id, jsonOps: jsonOps}, error.message
  194. return callback(error)
  195. newDocLines = JSON.stringify(docLines)
  196. if newDocLines.indexOf("\u0000") != -1
  197. error = new Error("null bytes found in doc lines")
  198. logger.error {err: error, doc_id: doc_id, newDocLines: newDocLines}, error.message
  199. return callback(error)
  200. newHash = RedisManager._computeHash(newDocLines)
  201. opVersions = appliedOps.map (op) -> op?.v
  202. logger.log doc_id: doc_id, version: newVersion, hash: newHash, op_versions: opVersions, "updating doc in redis"
  203. RedisManager._serializeRanges ranges, (error, ranges) ->
  204. if error?
  205. logger.error {err: error, doc_id}, error.message
  206. return callback(error)
  207. if ranges? and ranges.indexOf("\u0000") != -1
  208. error = new Error("null bytes found in ranges")
  209. logger.error err: error, doc_id: doc_id, ranges: ranges, error.message
  210. return callback(error)
  211. multi = rclient.multi()
  212. multi.eval setScript, 1, keys.docLines(doc_id:doc_id), newDocLines # index 0
  213. multi.set keys.docVersion(doc_id:doc_id), newVersion # index 1
  214. multi.set keys.docHash(doc_id:doc_id), newHash # index 2
  215. multi.ltrim keys.docOps(doc_id: doc_id), -RedisManager.DOC_OPS_MAX_LENGTH, -1 # index 3
  216. if ranges?
  217. multi.set keys.ranges(doc_id:doc_id), ranges # index 4
  218. else
  219. multi.del keys.ranges(doc_id:doc_id) # also index 4
  220. # push the ops last so we can get the lengths at fixed index position 7
  221. if jsonOps.length > 0
  222. multi.rpush keys.docOps(doc_id: doc_id), jsonOps... # index 5
  223. # expire must come after rpush since before it will be a no-op if the list is empty
  224. multi.expire keys.docOps(doc_id: doc_id), RedisManager.DOC_OPS_TTL # index 6
  225. multi.rpush historyKeys.uncompressedHistoryOps(doc_id: doc_id), jsonOps... # index 7
  226. # Set the unflushed timestamp to the current time if the doc
  227. # hasn't been modified before (the content in mongo has been
  228. # valid up to this point). Otherwise leave it alone ("NX" flag).
  229. multi.set keys.unflushedTime(doc_id: doc_id), Date.now(), "NX"
  230. multi.exec (error, result) ->
  231. return callback(error) if error?
  232. # check the hash computed on the redis server
  233. writeHash = result?[0]
  234. if logHashWriteErrors and writeHash? and writeHash isnt newHash
  235. logger.error doc_id: doc_id, writeHash: writeHash, origHash: newHash, docLines:newDocLines, "hash mismatch on updateDocument"
  236. # length of uncompressedHistoryOps queue (index 7)
  237. docUpdateCount = result[7]
  238. if jsonOps.length > 0 && Settings.apis?.project_history?.enabled
  239. ProjectHistoryRedisManager.queueOps project_id, jsonOps..., (error, projectUpdateCount) ->
  240. callback null, docUpdateCount, projectUpdateCount
  241. else
  242. callback null, docUpdateCount
  243. renameDoc: (project_id, doc_id, user_id, update, callback = (error) ->) ->
  244. RedisManager.getDoc project_id, doc_id, (error, lines, version) ->
  245. return callback(error) if error?
  246. if lines? and version?
  247. rclient.set keys.pathname(doc_id:doc_id), update.newPathname, (error) ->
  248. return callback(error) if error?
  249. ProjectHistoryRedisManager.queueRenameEntity project_id, 'doc', doc_id, user_id, update, callback
  250. else
  251. ProjectHistoryRedisManager.queueRenameEntity project_id, 'doc', doc_id, user_id, update, callback
  252. clearUnflushedTime: (doc_id, callback = (error) ->) ->
  253. rclient.del keys.unflushedTime(doc_id:doc_id), callback
  254. getDocIdsInProject: (project_id, callback = (error, doc_ids) ->) ->
  255. rclient.smembers keys.docsInProject(project_id: project_id), callback
  256. _serializeRanges: (ranges, callback = (error, serializedRanges) ->) ->
  257. jsonRanges = JSON.stringify(ranges)
  258. if jsonRanges? and jsonRanges.length > MAX_RANGES_SIZE
  259. return callback new Error("ranges are too large")
  260. if jsonRanges == '{}'
  261. # Most doc will have empty ranges so don't fill redis with lots of '{}' keys
  262. jsonRanges = null
  263. return callback null, jsonRanges
  264. _deserializeRanges: (ranges) ->
  265. if !ranges? or ranges == ""
  266. return {}
  267. else
  268. return JSON.parse(ranges)
  269. _computeHash: (docLines) ->
  270. # use sha1 checksum of doclines to detect data corruption.
  271. #
  272. # note: must specify 'utf8' encoding explicitly, as the default is
  273. # binary in node < v5
  274. return crypto.createHash('sha1').update(docLines, 'utf8').digest('hex')