RedisManager.coffee 14 KB

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