RedisManager.coffee 14 KB

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