persist_buffer.js 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. // @ts-check
  2. 'use strict'
  3. const logger = require('@overleaf/logger')
  4. const metrics = require('@overleaf/metrics')
  5. const OError = require('@overleaf/o-error')
  6. const assert = require('./assert')
  7. const chunkStore = require('./chunk_store')
  8. const { BlobStore } = require('./blob_store')
  9. const BatchBlobStore = require('./batch_blob_store')
  10. const persistChanges = require('./persist_changes')
  11. const resyncProject = require('./resync_project')
  12. const redisBackend = require('./chunk_store/redis')
  13. const PERSIST_BATCH_SIZE = 50
  14. /**
  15. * Persist the changes from Redis buffer to the main storage
  16. *
  17. * Algorithm Outline:
  18. * 1. Get the latest chunk's endVersion from the database
  19. * 2. Get non-persisted changes from Redis that are after this endVersion.
  20. * 3. If no such changes, exit.
  21. * 4. Load file blobs for these Redis changes.
  22. * 5. Run the persistChanges() algorithm to store these changes into a new chunk(s) in GCS.
  23. * - This must not decrease the endVersion. If changes were processed, it must advance.
  24. * 6. Set the new persisted version (endVersion of the latest persisted chunk) in Redis.
  25. *
  26. * @param {string} projectId
  27. * @param {Object} limits
  28. * @throws {Error | OError} If a critical error occurs during persistence.
  29. */
  30. async function persistBuffer(projectId, limits) {
  31. assert.projectId(projectId)
  32. logger.debug({ projectId }, 'starting persistBuffer operation')
  33. // 1. Get the latest chunk's endVersion from GCS/main store
  34. let endVersion
  35. const latestChunkMetadata = await chunkStore.getLatestChunkMetadata(projectId)
  36. if (latestChunkMetadata) {
  37. endVersion = latestChunkMetadata.endVersion
  38. } else {
  39. endVersion = 0 // No chunks found, start from version 0
  40. logger.debug({ projectId }, 'no existing chunks found in main storage')
  41. }
  42. const originalEndVersion = endVersion
  43. logger.debug({ projectId, endVersion }, 'got latest persisted chunk')
  44. // Process changes in batches
  45. let numberOfChangesPersisted = 0
  46. let currentChunk = null
  47. let resyncNeeded = false
  48. let resyncChangesWerePersisted = false
  49. while (true) {
  50. // 2. Get non-persisted changes from Redis
  51. const changesToPersist = await redisBackend.getNonPersistedChanges(
  52. projectId,
  53. endVersion,
  54. { maxChanges: PERSIST_BATCH_SIZE }
  55. )
  56. if (changesToPersist.length === 0) {
  57. break
  58. }
  59. logger.debug(
  60. {
  61. projectId,
  62. endVersion,
  63. count: changesToPersist.length,
  64. },
  65. 'found changes in Redis to persist'
  66. )
  67. // 4. Load file blobs for these Redis changes. Errors will propagate.
  68. const blobStore = new BlobStore(projectId)
  69. const batchBlobStore = new BatchBlobStore(blobStore)
  70. const blobHashes = new Set()
  71. for (const change of changesToPersist) {
  72. change.findBlobHashes(blobHashes)
  73. }
  74. if (blobHashes.size > 0) {
  75. await batchBlobStore.preload(Array.from(blobHashes))
  76. }
  77. for (const change of changesToPersist) {
  78. await change.loadFiles('lazy', blobStore)
  79. }
  80. // 5. Run the persistChanges() algorithm. Errors will propagate.
  81. logger.debug(
  82. {
  83. projectId,
  84. endVersion,
  85. changeCount: changesToPersist.length,
  86. },
  87. 'calling persistChanges'
  88. )
  89. const persistResult = await persistChanges(
  90. projectId,
  91. changesToPersist,
  92. limits,
  93. endVersion
  94. )
  95. if (!persistResult || !persistResult.currentChunk) {
  96. metrics.inc('persist_buffer', 1, { status: 'no-chunk-error' })
  97. throw new OError(
  98. 'persistChanges did not produce a new chunk for non-empty changes',
  99. {
  100. projectId,
  101. endVersion,
  102. changeCount: changesToPersist.length,
  103. }
  104. )
  105. }
  106. currentChunk = persistResult.currentChunk
  107. const newEndVersion = currentChunk.getEndVersion()
  108. if (newEndVersion <= endVersion) {
  109. metrics.inc('persist_buffer', 1, { status: 'chunk-version-mismatch' })
  110. throw new OError(
  111. 'persisted chunk endVersion must be greater than current persisted chunk end version for non-empty changes',
  112. {
  113. projectId,
  114. newEndVersion,
  115. endVersion,
  116. changeCount: changesToPersist.length,
  117. }
  118. )
  119. }
  120. logger.debug(
  121. {
  122. projectId,
  123. oldVersion: endVersion,
  124. newVersion: newEndVersion,
  125. },
  126. 'successfully persisted changes from Redis to main storage'
  127. )
  128. // 6. Set the persisted version in Redis. Errors will propagate.
  129. const status = await redisBackend.setPersistedVersion(
  130. projectId,
  131. newEndVersion
  132. )
  133. if (status !== 'ok') {
  134. metrics.inc('persist_buffer', 1, { status: 'error-on-persisted-version' })
  135. throw new OError('failed to update persisted version in Redis', {
  136. projectId,
  137. newEndVersion,
  138. status,
  139. })
  140. }
  141. logger.debug(
  142. { projectId, newEndVersion },
  143. 'updated persisted version in Redis'
  144. )
  145. numberOfChangesPersisted += persistResult.numberOfChangesPersisted
  146. endVersion = newEndVersion
  147. // Check if a resync might be needed
  148. if (persistResult.resyncNeeded) {
  149. resyncNeeded = true
  150. }
  151. if (
  152. changesToPersist.some(
  153. change => change.getOrigin()?.getKind() === 'history-resync'
  154. )
  155. ) {
  156. resyncChangesWerePersisted = true
  157. }
  158. if (persistResult.numberOfChangesPersisted < PERSIST_BATCH_SIZE) {
  159. // We reached the end of available changes
  160. break
  161. }
  162. }
  163. if (numberOfChangesPersisted === 0) {
  164. logger.debug(
  165. { projectId, endVersion },
  166. 'no new changes in Redis buffer to persist'
  167. )
  168. metrics.inc('persist_buffer', 1, { status: 'no_changes' })
  169. // No changes to persist, update the persisted version in Redis
  170. // to match the current endVersion. This shouldn't be needed
  171. // unless a worker failed to update the persisted version.
  172. await redisBackend.setPersistedVersion(projectId, endVersion)
  173. } else {
  174. logger.debug(
  175. { projectId, finalPersistedVersion: endVersion },
  176. 'persistBuffer operation completed successfully'
  177. )
  178. metrics.inc('persist_buffer', 1, { status: 'persisted' })
  179. }
  180. if (limits.autoResync && resyncNeeded) {
  181. if (resyncChangesWerePersisted) {
  182. // To avoid an infinite loop, do not resync if the current batch of
  183. // changes contains a history resync.
  184. logger.warn(
  185. { projectId },
  186. 'content hash validation failed while persisting a history resync, skipping additional resync'
  187. )
  188. } else {
  189. const backend = chunkStore.getBackend(projectId)
  190. const mongoProjectId =
  191. await backend.resolveHistoryIdToMongoProjectId(projectId)
  192. await resyncProject(mongoProjectId)
  193. }
  194. }
  195. if (currentChunk == null) {
  196. const { chunk } = await chunkStore.loadByChunkRecord(
  197. projectId,
  198. latestChunkMetadata
  199. )
  200. currentChunk = chunk
  201. }
  202. return {
  203. numberOfChangesPersisted,
  204. originalEndVersion,
  205. currentChunk,
  206. resyncNeeded,
  207. }
  208. }
  209. module.exports = persistBuffer