debug_history.js 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. // Script to debug the track-changes history of the documents in a project.
  2. // Usage:
  3. // node debug_history.js --project-id=<project_id>
  4. // node debug_history.js --all # to check all unmigrated projects
  5. //
  6. // Example output:
  7. // $ node scripts/debug_history.js --project-id=63ff3adc06177192f18a6b38
  8. // Using default settings from /overleaf/services/track-changes/config/settings.defaults.js
  9. // Set UV_THREADPOOL_SIZE=16
  10. // project 63ff3adc06177192f18a6b38 docId 63ff3adc06177192f18a6b3d OK
  11. // project 63ff3adc06177192f18a6b38 docId 63ff3b08de41e3b0989c1720 FAILED
  12. // {"action":"rewinding","version":7,"meta":{"start_ts":1677671465447,"end_ts":1677671465447,"user_id":"632ae106f9a6dd002505765b"},
  13. // "ops":[{"action":"rewindOp","contentLength":24,"op":{"p":32,"d":6},"errors":[{"message":"invalid offset rewinding delete,
  14. // truncating to content length","op":{"p":32,"d":6},"contentLength":24}]}],"status":"failed"}
  15. /* eslint-disable camelcase */
  16. const TrackChangesMongoDb = require('../../../track-changes/app/js/mongodb')
  17. const { waitForDb } = require('../../app/src/infrastructure/mongodb')
  18. const {
  19. findProjects,
  20. } = require('../../modules/history-migration/app/src/HistoryUpgradeHelper')
  21. const PackManager = require('../../../track-changes/app/js/PackManager')
  22. const {
  23. packsAreDuplicated,
  24. } = require('../../../track-changes/app/js/util/PackUtils')
  25. const {
  26. ConsistencyError,
  27. } = require('../../../track-changes/app/js/DiffGenerator')
  28. const DocumentUpdaterManager = require('../../../track-changes/app/js/DocumentUpdaterManager')
  29. const DocstoreManager = require('../../../track-changes/app/js/DocstoreManager')
  30. const Errors = require('../../../track-changes/app/js/Errors')
  31. const minimist = require('minimist')
  32. const util = require('util')
  33. const logger = require('@overleaf/logger')
  34. logger.initialize('debug-history')
  35. // disable logging to stdout from internal modules
  36. logger.logger.streams = []
  37. const options = {
  38. boolean: ['all', 'verbose', 'raw', 'help'],
  39. string: ['project-id'],
  40. alias: {
  41. 'project-id': 'p',
  42. verbose: 'v',
  43. raw: 'r',
  44. help: 'h',
  45. all: 'a',
  46. },
  47. default: {},
  48. }
  49. const argv = minimist(process.argv.slice(2), options)
  50. function usage() {
  51. console.log(
  52. `Usage: ${process.argv[1]} [--project-id=<project_id> | --all] [--verbose] [--raw]`
  53. )
  54. process.exit(1)
  55. }
  56. // look in docstore or docupdater for the latest version of the document
  57. async function getLatestContent(projectId, docId, lastUpdateVersion) {
  58. const [docstoreContent, docstoreVersion] =
  59. await DocstoreManager.promises.peekDocument(projectId, docId)
  60. // if docstore is out of date, check for a newer version in docupdater
  61. // and return that instead
  62. if (docstoreVersion <= lastUpdateVersion) {
  63. const [docupdaterContent, docupdaterVersion] =
  64. await DocumentUpdaterManager.promises.peekDocument(projectId, docId)
  65. if (docupdaterVersion > docstoreVersion) {
  66. return [docupdaterContent, docupdaterVersion]
  67. }
  68. }
  69. return [docstoreContent, docstoreVersion]
  70. }
  71. // This class is used to write a record of all the operations that have been applied to a document
  72. class LogAppliedOps {
  73. constructor() {
  74. this.result = []
  75. }
  76. // used to log the initial state of the document
  77. start(action, latestContent, version) {
  78. this.result.push({
  79. action,
  80. latestContentLength: latestContent.length,
  81. latestContent: argv.raw ? latestContent : undefined,
  82. version,
  83. })
  84. }
  85. // used to log a new document update
  86. update(action, update) {
  87. this._finalize()
  88. this.opResults = []
  89. this.currentResult = {
  90. action,
  91. version: update.v,
  92. meta: update.meta,
  93. ops: this.opResults,
  94. }
  95. this.result.push(this.currentResult)
  96. }
  97. // used to log an operation that has been applied to the document
  98. op(action, content, op) {
  99. this.currentOp = {
  100. action,
  101. contentLength: content.length,
  102. content: argv.raw ? content : undefined,
  103. op: this._filterOp(op),
  104. }
  105. this.opResults.push(this.currentOp)
  106. }
  107. // used to log an error that occurred while applying an operation
  108. opError(message, content, op, err) {
  109. this.currentOp.errors = this.currentOp.errors || []
  110. this.currentOp.errors.push({
  111. message,
  112. op: this._filterOp(op),
  113. contentLength: content.length,
  114. content: argv.raw ? content : undefined,
  115. err,
  116. })
  117. }
  118. // sets the status of the current update to 'success' or 'failed'
  119. // depending on whether any errors were logged
  120. _finalize() {
  121. if (!this.currentResult) {
  122. return
  123. }
  124. const errors = this.opResults.some(op => op.errors)
  125. this.currentResult.status = errors ? 'failed' : 'success'
  126. }
  127. // returns the final result of the log
  128. end() {
  129. this._finalize()
  130. return this.result
  131. }
  132. // Returns a new object with the same keys as op, but with the i and d
  133. // fields replaced by their lengths when present. This is used to filter
  134. // out the contents of the i and d fields of an operation, to redact
  135. // document content.
  136. _filterOp(op) {
  137. const newOp = {}
  138. for (const key of Object.keys(op)) {
  139. if (!argv.raw && (key === 'i' || key === 'd')) {
  140. newOp[key] = op[key].length
  141. } else {
  142. newOp[key] = op[key]
  143. }
  144. }
  145. return newOp
  146. }
  147. }
  148. // This is the rewindOp function from track-changes, modified to log
  149. // the operation and any errors.
  150. function rewindOp(content, op, log) {
  151. if (op.i != null) {
  152. // ShareJS will accept an op where p > content.length when applied,
  153. // and it applies as though p == content.length. However, the op is
  154. // passed to us with the original p > content.length. Detect if that
  155. // is the case with this op, and shift p back appropriately to match
  156. // ShareJS if so.
  157. let { p } = op
  158. const maxP = content.length - op.i.length
  159. if (p > maxP) {
  160. log.opError(
  161. 'invalid offset rewinding insert, truncating to content length',
  162. content,
  163. op
  164. )
  165. p = maxP
  166. }
  167. const textToBeRemoved = content.slice(p, p + op.i.length)
  168. if (op.i !== textToBeRemoved) {
  169. log.opError(
  170. 'inserted content does not match text to be removed',
  171. content,
  172. op
  173. )
  174. throw new ConsistencyError(
  175. `Inserted content, '${op.i}', does not match text to be removed, '${textToBeRemoved}'`
  176. )
  177. }
  178. return content.slice(0, p) + content.slice(p + op.i.length)
  179. } else if (op.d != null) {
  180. if (op.p > content.length) {
  181. log.opError(
  182. 'invalid offset rewinding delete, truncating to content length',
  183. content,
  184. op
  185. )
  186. }
  187. return content.slice(0, op.p) + op.d + content.slice(op.p)
  188. } else {
  189. return content
  190. }
  191. }
  192. // This is the rewindDoc function from track-changes, modified to log all
  193. // operations that are applied to the document.
  194. async function rewindDoc(projectId, docId) {
  195. const log = new LogAppliedOps()
  196. // Prepare to rewind content
  197. const docIterator = await PackManager.promises.makeDocIterator(docId)
  198. const getUpdate = util.promisify(docIterator.next).bind(docIterator)
  199. const lastUpdate = await getUpdate()
  200. if (!lastUpdate) {
  201. return null
  202. }
  203. const lastUpdateVersion = lastUpdate.v
  204. let latestContent
  205. let version
  206. try {
  207. ;[latestContent, version] = await getLatestContent(
  208. projectId,
  209. docId,
  210. lastUpdateVersion
  211. )
  212. } catch (err) {
  213. if (err instanceof Errors.NotFoundError) {
  214. // Doc not found in docstore. We can't build its history
  215. return null
  216. } else {
  217. throw err
  218. }
  219. }
  220. log.start('load-doc', latestContent, version)
  221. let content = latestContent
  222. let update = lastUpdate
  223. let previousUpdate = null
  224. while (update) {
  225. if (packsAreDuplicated(update, previousUpdate)) {
  226. previousUpdate = update
  227. update = await getUpdate()
  228. continue
  229. }
  230. log.update('rewinding', update)
  231. for (let i = update.op.length - 1; i >= 0; i--) {
  232. const op = update.op[i]
  233. if (op.broken === true) {
  234. log.op('skipped', content, op)
  235. continue
  236. }
  237. try {
  238. log.op('rewindOp', content, op)
  239. content = rewindOp(content, op, log)
  240. } catch (e) {
  241. if (e instanceof ConsistencyError && (i = update.op.length - 1)) {
  242. // catch known case where the last op in an array has been
  243. // merged into a later op
  244. op.broken = true
  245. log.opError('marking broken', content, op)
  246. } else {
  247. log.opError('failed', content, op, e)
  248. }
  249. }
  250. }
  251. previousUpdate = update
  252. update = await getUpdate()
  253. }
  254. return log.end()
  255. }
  256. async function main() {
  257. // Get a list of projects to migrate
  258. let projectIds = []
  259. if (argv.all) {
  260. const projectsToMigrate = await findProjects(
  261. { 'overleaf.history.display': { $ne: true } },
  262. { _id: 1, overleaf: 1 }
  263. )
  264. projectIds = projectsToMigrate.map(p => p._id.toString())
  265. console.log('Unmigrated projects', projectIds.length)
  266. } else if (argv['project-id']) {
  267. projectIds = [argv['project-id']]
  268. } else {
  269. usage()
  270. process.exit(1)
  271. }
  272. let errorCount = 0
  273. for (const projectId of projectIds) {
  274. const docIds = await PackManager.promises.findAllDocsInProject(projectId)
  275. if (!docIds.length) {
  276. console.log('No docs found for project', projectId)
  277. }
  278. let projectErrorCount = 0
  279. for (const docId of docIds) {
  280. const result = await rewindDoc(projectId, docId)
  281. const failed = result.filter(r => r.status === 'failed')
  282. errorCount += failed.length
  283. if (argv.verbose) {
  284. console.log(JSON.stringify({ projectId, docId, result }, null, 2))
  285. } else if (failed.length > 0) {
  286. console.log('project', projectId, 'docId', docId, 'FAILED')
  287. for (const f of failed) {
  288. console.log(JSON.stringify(f))
  289. }
  290. projectErrorCount += failed.length
  291. }
  292. }
  293. if (projectErrorCount === 0 && !argv.verbose) {
  294. console.log('project', projectId, 'docs', docIds.length, 'OK')
  295. }
  296. }
  297. process.exit(errorCount > 0 ? 1 : 0)
  298. }
  299. waitForDb()
  300. .then(TrackChangesMongoDb.waitForDb)
  301. .then(main)
  302. .catch(err => {
  303. console.error(err)
  304. process.exit(1)
  305. })