PackManager.coffee 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. async = require "async"
  2. _ = require "underscore"
  3. {db, ObjectId} = require "./mongojs"
  4. BSON=db.bson.BSON
  5. logger = require "logger-sharelatex"
  6. LockManager = require "./LockManager"
  7. module.exports = PackManager =
  8. # The following functions implement methods like a mongo find, but
  9. # expands any documents containing a 'pack' field into multiple
  10. # values
  11. #
  12. # e.g. a single update looks like
  13. #
  14. # {
  15. # "doc_id" : 549dae9e0a2a615c0c7f0c98,
  16. # "project_id" : 549dae9c0a2a615c0c7f0c8c,
  17. # "op" : [ {"p" : 6981, "d" : "?" } ],
  18. # "meta" : { "user_id" : 52933..., "start_ts" : 1422310693931, "end_ts" : 1422310693931 },
  19. # "v" : 17082
  20. # }
  21. #
  22. # and a pack looks like this
  23. #
  24. # {
  25. # "doc_id" : 549dae9e0a2a615c0c7f0c98,
  26. # "project_id" : 549dae9c0a2a615c0c7f0c8c,
  27. # "pack" : [ U1, U2, U3, ...., UN],
  28. # "meta" : { "user_id" : 52933..., "start_ts" : 1422310693931, "end_ts" : 1422310693931 },
  29. # "v" : 17082
  30. # }
  31. #
  32. # where U1, U2, U3, .... are single updates stripped of their
  33. # doc_id and project_id fields (which are the same for all the
  34. # updates in the pack).
  35. #
  36. # The pack itself has v and meta fields, this makes it possible to
  37. # treat packs and single updates in the same way.
  38. #
  39. # The v field of the pack itself is from the first entry U1
  40. # The meta.end_ts field of the pack itself is from the last entry UN.
  41. findDocResults: (collection, query, limit, callback) ->
  42. # query - the mongo query selector, includes both the doc_id/project_id and
  43. # the range on v
  44. # limit - the mongo limit, we need to apply it after unpacking any
  45. # packs
  46. sort = {}
  47. sort['v'] = -1;
  48. cursor = collection
  49. .find( query )
  50. .sort( sort )
  51. # if we have packs, we will trim the results more later after expanding them
  52. if limit?
  53. cursor.limit(limit)
  54. # take the part of the query which selects the range over the parameter
  55. rangeQuery = query['v']
  56. # helper function to check if an item from a pack is inside the
  57. # desired range
  58. filterFn = (item) ->
  59. return false if rangeQuery?['$gte']? && item['v'] < rangeQuery['$gte']
  60. return false if rangeQuery?['$lte']? && item['v'] > rangeQuery['$lte']
  61. return false if rangeQuery?['$lt']? && item['v'] >= rangeQuery['$lt']
  62. return false if rangeQuery?['$gt']? && item['v'] <= rangeQuery['$gt']
  63. return true
  64. versionOrder = (a, b) ->
  65. b.v - a.v
  66. # create a query which can be used to select the entries BEFORE
  67. # the range because we sometimes need to find extra ones (when the
  68. # boundary falls in the middle of a pack)
  69. extraQuery = _.clone(query)
  70. # The pack uses its first entry for its metadata and v, so the
  71. # only queries where we might not get all the packs are those for
  72. # $gt and $gte (i.e. we need to find packs which start before our
  73. # range but end in it)
  74. if rangeQuery?['$gte']?
  75. extraQuery['v'] = {'$lt' : rangeQuery['$gte']}
  76. else if rangeQuery?['$gt']
  77. extraQuery['v'] = {'$lte' : rangeQuery['$gt']}
  78. else
  79. delete extraQuery['v']
  80. needMore = false # keep track of whether we need to load more data
  81. updates = [] # used to accumulate the set of results
  82. cursor.toArray (err, result) ->
  83. unpackedSet = PackManager._unpackResults(result)
  84. updates = PackManager._filterAndLimit(updates, unpackedSet, filterFn, limit)
  85. # check if we need to retrieve more data, because there is a
  86. # pack that crosses into our range
  87. last = if unpackedSet.length then unpackedSet[unpackedSet.length-1] else null
  88. if limit? && updates.length == limit
  89. needMore = false
  90. else if extraQuery['v']? && last? && filterFn(last)
  91. needMore = true
  92. else if extraQuery['v']? && updates.length == 0
  93. needMore = true
  94. if needMore
  95. # we do need an extra result set
  96. extra = collection
  97. .find(extraQuery)
  98. .sort(sort)
  99. .limit(1)
  100. extra.toArray (err, result2) ->
  101. if err?
  102. return callback err, updates.sort versionOrder
  103. else
  104. extraSet = PackManager._unpackResults(result2)
  105. updates = PackManager._filterAndLimit(updates, extraSet, filterFn, limit)
  106. callback err, updates.sort versionOrder
  107. return
  108. if err?
  109. callback err, result
  110. else
  111. callback err, updates.sort versionOrder
  112. findProjectResults: (collection, query, limit, callback) ->
  113. # query - the mongo query selector, includes both the doc_id/project_id and
  114. # the range on meta.end_ts
  115. # limit - the mongo limit, we need to apply it after unpacking any
  116. # packs
  117. sort = {}
  118. sort['meta.end_ts'] = -1;
  119. projection = {"op":false, "pack.op": false}
  120. cursor = collection
  121. .find( query, projection ) # no need to return the op only need version info
  122. .sort( sort )
  123. # if we have packs, we will trim the results more later after expanding them
  124. if limit?
  125. cursor.limit(limit)
  126. # take the part of the query which selects the range over the parameter
  127. before = query['meta.end_ts']?['$lt'] # may be null
  128. updates = [] # used to accumulate the set of results
  129. cursor.toArray (err, result) ->
  130. if err?
  131. return callback err, result
  132. if result.length == 0 && not before? # no results and no time range specified
  133. return callback err, result
  134. unpackedSet = PackManager._unpackResults(result)
  135. if limit?
  136. unpackedSet = unpackedSet.slice(0, limit)
  137. # find the end time of the last result, we will take all the
  138. # results up to this, and then all the changes at that time
  139. # (without imposing a limit) and any overlapping packs
  140. cutoff = if unpackedSet.length then unpackedSet[unpackedSet.length-1].meta.end_ts else null
  141. filterFn = (item) ->
  142. ts = item?.meta?.end_ts
  143. return false if before? && ts >= before
  144. return false if cutoff? && ts < cutoff
  145. return true
  146. timeOrder = (a, b) ->
  147. (b.meta.end_ts - a.meta.end_ts) || documentOrder(a, b)
  148. documentOrder = (a, b) ->
  149. x = a.doc_id.valueOf()
  150. y = b.doc_id.valueOf()
  151. if x > y then 1 else if x < y then -1 else 0
  152. updates = PackManager._filterAndLimit(updates, unpackedSet, filterFn, limit)
  153. # get all elements on the lower bound (cutoff)
  154. tailQuery = _.clone(query)
  155. tailQuery['meta.end_ts'] = cutoff
  156. tail = collection
  157. .find(tailQuery, projection)
  158. .sort(sort)
  159. # now find any packs that overlap with the time window
  160. overlapQuery = _.clone(query)
  161. if before? && cutoff?
  162. overlapQuery['meta.end_ts'] = {"$gte": before}
  163. overlapQuery['pack.0.meta.end_ts'] = {"$lte": before }
  164. else if before? && not cutoff?
  165. overlapQuery['meta.end_ts'] = {"$gte": before}
  166. overlapQuery['pack.0.meta.end_ts'] = {"$lte": before }
  167. else if not before? && cutoff?
  168. overlapQuery['meta.end_ts'] = {"$gte": cutoff}
  169. overlapQuery['pack.0.meta.end_ts'] = {"$gte": 0 }
  170. else if not before? && not cutoff?
  171. overlapQuery['meta.end_ts'] = {"$gte": 0 }
  172. overlapQuery['pack.0.meta.end_ts'] = {"$gte": 0 }
  173. overlap = collection
  174. .find(overlapQuery, projection)
  175. .sort(sort)
  176. # we don't specify a limit here, as there could be any number of overlaps
  177. # NB. need to catch items in original query and followup query for duplicates
  178. applyAndUpdate = (result) ->
  179. extraSet = PackManager._unpackResults(result)
  180. # note: final argument is null, no limit applied because we
  181. # need all the updates at the final time to avoid breaking
  182. # the changeset into parts
  183. updates = PackManager._filterAndLimit(updates, extraSet, filterFn, null)
  184. tail.toArray (err, result2) ->
  185. if err?
  186. return callback err, updates.sort timeOrder
  187. else
  188. applyAndUpdate result2
  189. overlap.toArray (err, result3) ->
  190. if err?
  191. return callback err, updates.sort timeOrder
  192. else
  193. applyAndUpdate result3
  194. callback err, updates.sort timeOrder
  195. _unpackResults: (updates) ->
  196. # iterate over the updates, if there's a pack, expand it into ops and
  197. # insert it into the array at that point
  198. result = []
  199. updates.forEach (item) ->
  200. if item.pack?
  201. all = PackManager._explodePackToOps item
  202. result = result.concat all
  203. else
  204. result.push item
  205. return result
  206. _explodePackToOps: (packObj) ->
  207. # convert a pack into an array of ops
  208. doc_id = packObj.doc_id
  209. project_id = packObj.project_id
  210. result = packObj.pack.map (item) ->
  211. item.doc_id = doc_id
  212. item.project_id = project_id
  213. item
  214. return result.reverse()
  215. _filterAndLimit: (results, extra, filterFn, limit) ->
  216. # update results with extra docs, after filtering and limiting
  217. filtered = extra.filter(filterFn)
  218. newResults = results.concat filtered
  219. # remove duplicates
  220. seen = {}
  221. newResults = newResults.filter (item) ->
  222. key = item.doc_id + ' ' + item.v
  223. if seen[key]
  224. return false
  225. else
  226. seen[key] = true
  227. return true
  228. newResults.slice(0, limit) if limit?
  229. return newResults
  230. MAX_SIZE: 1024*1024 # make these configurable parameters
  231. MAX_COUNT: 1024
  232. MIN_COUNT: 100
  233. KEEP_OPS: 100
  234. convertDocsToPacks: (docs, callback) ->
  235. packs = []
  236. top = null
  237. # keep the last KEEP_OPS as individual ops
  238. docs = docs.slice(0,-PackManager.KEEP_OPS)
  239. docs.forEach (d,i) ->
  240. # skip existing packs
  241. if d.pack?
  242. top = null
  243. return
  244. # skip temporary ops (we could pack these into temporary packs in future)
  245. if d.expiresAt?
  246. top = null
  247. return
  248. sz = BSON.calculateObjectSize(d)
  249. if top? && top.pack.length < PackManager.MAX_COUNT && top.sz + sz < PackManager.MAX_SIZE
  250. top.pack = top.pack.concat {v: d.v, meta: d.meta, op: d.op, _id: d._id}
  251. top.sz += sz
  252. top.v_end = d.v
  253. top.meta.end_ts = d.meta.end_ts
  254. return
  255. else if sz < PackManager.MAX_SIZE
  256. # create a new pack
  257. top = _.clone(d)
  258. top.pack = [ {v: d.v, meta: d.meta, op: d.op, _id: d._id} ]
  259. top.meta = { start_ts: d.meta.start_ts, end_ts: d.meta.end_ts }
  260. top.sz = sz
  261. delete top.op
  262. delete top._id
  263. packs.push top
  264. else
  265. # keep the op
  266. # util.log "keeping large op unchanged (#{sz} bytes)"
  267. # only store packs with a sufficient number of ops, discard others
  268. packs = packs.filter (packObj) ->
  269. packObj.pack.length > PackManager.MIN_COUNT
  270. callback(null, packs)
  271. checkHistory: (docs, callback) ->
  272. errors = []
  273. prev = null
  274. error = (args...) ->
  275. errors.push args
  276. docs.forEach (d,i) ->
  277. if d.pack?
  278. n = d.pack.length
  279. last = d.pack[n-1]
  280. error('bad pack v_end', d) if d.v_end != last.v
  281. error('bad pack start_ts', d) if d.meta.start_ts != d.pack[0].meta.start_ts
  282. error('bad pack end_ts', d) if d.meta.end_ts != last.meta.end_ts
  283. d.pack.forEach (p, i) ->
  284. prev = v
  285. v = p.v
  286. error('bad version', v, 'in', p) if v <= prev
  287. #error('expired op', p, 'in pack') if p.expiresAt?
  288. else
  289. prev = v
  290. v = d.v
  291. error('bad version', v, 'in', d) if v <= prev
  292. if errors.length
  293. callback(errors)
  294. else
  295. callback()
  296. insertPack: (packObj, callback) ->
  297. bulk = db.docHistory.initializeOrderedBulkOp()
  298. doc_id = packObj.doc_id
  299. expect_nInserted = 1
  300. expect_nRemoved = packObj.pack.length
  301. logger.log {doc_id: doc_id}, "adding pack, removing #{expect_nRemoved} ops"
  302. bulk.insert packObj
  303. packObj.pack.forEach (op) ->
  304. bulk.find({_id:op._id}).removeOne()
  305. bulk.execute (err, result) ->
  306. if err?
  307. logger.error {doc_id: doc_id}, "error adding pack"
  308. callback(err, result)
  309. else if result.nInserted != expect_nInserted or result.nRemoved != expect_nRemoved
  310. logger.error {doc_id: doc_id, result}, "unexpected result adding pack"
  311. callback(new Error(
  312. msg: 'unexpected result'
  313. expected: {expect_nInserted, expect_nRemoved}
  314. ), result)
  315. else
  316. db.docHistoryStats.update {doc_id:doc_id}, {
  317. $inc:{update_count:-expect_nRemoved},
  318. $currentDate:{last_packed:true}
  319. }, {upsert:true}, () ->
  320. callback(err, result)
  321. # retrieve document ops/packs and check them
  322. getDocHistory: (doc_id, callback) ->
  323. db.docHistory.find({doc_id:ObjectId(doc_id)}).sort {v:1}, (err, docs) ->
  324. return callback(err) if err?
  325. # for safety, do a consistency check of the history
  326. logger.log {doc_id}, "checking history for document"
  327. PackManager.checkHistory docs, (err) ->
  328. return callback(err) if err?
  329. callback(err, docs)
  330. #PackManager.deleteExpiredPackOps docs, (err) ->
  331. # return callback(err) if err?
  332. # callback err, docs
  333. packDocHistory: (doc_id, options, callback) ->
  334. if typeof callback == "undefined" and typeof options == 'function'
  335. callback = options
  336. options = {}
  337. LockManager.runWithLock(
  338. "HistoryLock:#{doc_id}",
  339. (releaseLock) ->
  340. PackManager._packDocHistory(doc_id, options, releaseLock)
  341. , callback
  342. )
  343. _packDocHistory: (doc_id, options, callback) ->
  344. logger.log {doc_id},"starting pack operation for document history"
  345. PackManager.getDocHistory doc_id, (err, docs) ->
  346. return callback(err) if err?
  347. origDocs = 0
  348. origPacks = 0
  349. for d in docs
  350. if d.pack? then origPacks++ else origDocs++
  351. PackManager.convertDocsToPacks docs, (err, packs) ->
  352. return callback(err) if err?
  353. total = 0
  354. for p in packs
  355. total = total + p.pack.length
  356. logger.log {doc_id, origDocs, origPacks, newPacks: packs.length, totalOps: total}, "document stats"
  357. if packs.length
  358. if options['dry-run']
  359. logger.log {doc_id}, 'dry-run, skipping write packs'
  360. return callback()
  361. PackManager.savePacks packs, (err) ->
  362. return callback(err) if err?
  363. # check the history again
  364. PackManager.getDocHistory doc_id, callback
  365. else
  366. logger.log {doc_id}, "no packs to write"
  367. # keep a record that we checked this one to avoid rechecking it
  368. db.docHistoryStats.update {doc_id:doc_id}, {
  369. $currentDate:{last_checked:true}
  370. }, {upsert:true}, () ->
  371. callback null, null
  372. DB_WRITE_DELAY: 2000
  373. savePacks: (packs, callback) ->
  374. async.eachSeries packs, PackManager.safeInsert, (err, result) ->
  375. if err?
  376. logger.log {err, result}, "error writing packs"
  377. callback err, result
  378. else
  379. callback()
  380. safeInsert: (packObj, callback) ->
  381. PackManager.insertPack packObj, (err, result) ->
  382. setTimeout () ->
  383. callback(err,result)
  384. , PackManager.DB_WRITE_DELAY
  385. deleteExpiredPackOps: (docs, callback) ->
  386. now = Date.now()
  387. toRemove = []
  388. toUpdate = []
  389. docs.forEach (d,i) ->
  390. if d.pack?
  391. newPack = d.pack.filter (op) ->
  392. if op.expiresAt? then op.expiresAt > now else true
  393. if newPack.length == 0
  394. toRemove.push d
  395. else if newPack.length < d.pack.length
  396. # adjust the pack properties
  397. d.pack = newPack
  398. first = d.pack[0]
  399. last = d.pack[d.pack.length - 1]
  400. d.v_end = last.v
  401. d.meta.start_ts = first.meta.start_ts
  402. d.meta.end_ts = last.meta.end_ts
  403. toUpdate.push d
  404. if toRemove.length or toUpdate.length
  405. bulk = db.docHistory.initializeOrderedBulkOp()
  406. toRemove.forEach (pack) ->
  407. console.log "would remove", pack
  408. #bulk.find({_id:pack._id}).removeOne()
  409. toUpdate.forEach (pack) ->
  410. console.log "would update", pack
  411. #bulk.find({_id:pack._id}).updateOne(pack);
  412. bulk.execute callback
  413. else
  414. callback()