finalise_chunk.mjs 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. // Finalise the current chunk for a project and start a new empty chunk
  2. // whose starting snapshot is the end snapshot of the (now-closed) current
  3. // chunk.
  4. //
  5. // This is intended as a recovery tool for projects whose current chunk has
  6. // become corrupted in such a way that further changes can no longer be
  7. // persisted, but where the end snapshot of the current chunk can still be
  8. // computed.
  9. import logger from '@overleaf/logger'
  10. import commandLineArgs from 'command-line-args'
  11. import { Change, Chunk, History, NoOperation } from 'overleaf-editor-core'
  12. import * as redis from '../lib/redis.js'
  13. import knex from '../lib/knex.js'
  14. import knexReadOnly from '../lib/knex_read_only.js'
  15. import { client as mongoClient } from '../lib/mongodb.js'
  16. import chunkStore from '../lib/chunk_store/index.js'
  17. import redisBackend from '../lib/chunk_store/redis.js'
  18. import { loadGlobalBlobs } from '../lib/blob_store/index.js'
  19. import { fileURLToPath } from 'node:url'
  20. import { EventEmitter } from 'node:events'
  21. EventEmitter.defaultMaxListeners = 20
  22. logger.initialize('finalise-chunk')
  23. const optionDefinitions = [
  24. { name: 'historyId', type: String },
  25. { name: 'dry-run', alias: 'd', type: Boolean },
  26. ]
  27. const options = commandLineArgs(optionDefinitions)
  28. const HISTORY_ID = options.historyId
  29. const DRY_RUN = options['dry-run'] || false
  30. if (!HISTORY_ID) {
  31. console.error('Usage: finalise_chunk.mjs --historyId <id> [--dry-run]')
  32. process.exit(2)
  33. }
  34. async function finaliseCurrentChunk(historyId) {
  35. // Validates the history id and selects the backend (postgres or mongo).
  36. chunkStore.getBackend(historyId)
  37. await loadGlobalBlobs()
  38. const currentChunk = await chunkStore.loadLatest(historyId, {
  39. persistedOnly: true,
  40. })
  41. const startVersion = currentChunk.getStartVersion()
  42. const endVersion = currentChunk.getEndVersion()
  43. const numChanges = currentChunk.getChanges().length
  44. logger.info(
  45. { historyId, startVersion, endVersion, numChanges },
  46. 'loaded current chunk'
  47. )
  48. if (endVersion === startVersion) {
  49. throw new Error(
  50. `current chunk for history ${historyId} is already empty (no changes); refusing to create another empty chunk`
  51. )
  52. }
  53. let nonPersistedChanges
  54. try {
  55. nonPersistedChanges = await redisBackend.getNonPersistedChanges(
  56. historyId,
  57. endVersion
  58. )
  59. } catch (err) {
  60. throw new Error(
  61. `unable to read non-persisted changes from redis for history ${historyId}: ${err.message}`
  62. )
  63. }
  64. if (nonPersistedChanges.length > 0) {
  65. throw new Error(
  66. `history ${historyId} has ${nonPersistedChanges.length} non-persisted change(s) in redis; persist or expire them before running this script`
  67. )
  68. }
  69. const endSnapshot = currentChunk.getSnapshot().clone()
  70. endSnapshot.applyAll(currentChunk.getChanges())
  71. // The chunks table has a unique constraint on (doc_id, end_version), so the
  72. // new chunk cannot share an end_version with the chunk we are closing. Add a
  73. // single NoOperation change to bump end_version by 1 without mutating the
  74. // snapshot.
  75. const recoveryChange = new Change([new NoOperation()], new Date(), [])
  76. const newChunk = new Chunk(
  77. new History(endSnapshot, [recoveryChange]),
  78. endVersion
  79. )
  80. if (DRY_RUN) {
  81. logger.info(
  82. { historyId, endVersion },
  83. 'dry run: would close current chunk and create new empty chunk'
  84. )
  85. return
  86. }
  87. await chunkStore.create(historyId, newChunk)
  88. logger.info(
  89. { historyId, endVersion },
  90. 'closed current chunk and created new empty chunk'
  91. )
  92. }
  93. async function main() {
  94. try {
  95. await finaliseCurrentChunk(HISTORY_ID)
  96. } catch (err) {
  97. logger.fatal({ err, historyId: HISTORY_ID }, 'failed to finalise chunk')
  98. process.exitCode = 1
  99. } finally {
  100. await redis.disconnect()
  101. await mongoClient.close()
  102. await knex.destroy()
  103. await knexReadOnly.destroy()
  104. }
  105. }
  106. const currentScriptPath = fileURLToPath(import.meta.url)
  107. if (process.argv[1] === currentScriptPath) {
  108. main()
  109. }
  110. export { finaliseCurrentChunk }