batchedUpdate.mjs 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. import mongodb from 'mongodb-legacy'
  2. import {
  3. db,
  4. waitForDb,
  5. READ_PREFERENCE_SECONDARY,
  6. } from '../../app/src/infrastructure/mongodb.js'
  7. const { ObjectId } = mongodb
  8. const ONE_MONTH_IN_MS = 1000 * 60 * 60 * 24 * 31
  9. let ID_EDGE_PAST
  10. const ID_EDGE_FUTURE = objectIdFromMs(Date.now() + 1000)
  11. let BATCH_DESCENDING
  12. let BATCH_SIZE
  13. let VERBOSE_LOGGING
  14. let BATCH_RANGE_START
  15. let BATCH_RANGE_END
  16. let BATCH_MAX_TIME_SPAN_IN_MS
  17. function refreshGlobalOptionsForBatchedUpdate(options = {}) {
  18. options = Object.assign({}, options, process.env)
  19. BATCH_DESCENDING = options.BATCH_DESCENDING === 'true'
  20. BATCH_SIZE = parseInt(options.BATCH_SIZE, 10) || 1000
  21. VERBOSE_LOGGING = options.VERBOSE_LOGGING === 'true'
  22. if (options.BATCH_LAST_ID) {
  23. BATCH_RANGE_START = new ObjectId(options.BATCH_LAST_ID)
  24. } else if (options.BATCH_RANGE_START) {
  25. BATCH_RANGE_START = new ObjectId(options.BATCH_RANGE_START)
  26. } else {
  27. if (BATCH_DESCENDING) {
  28. BATCH_RANGE_START = ID_EDGE_FUTURE
  29. } else {
  30. BATCH_RANGE_START = ID_EDGE_PAST
  31. }
  32. }
  33. BATCH_MAX_TIME_SPAN_IN_MS =
  34. parseInt(options.BATCH_MAX_TIME_SPAN_IN_MS, 10) || ONE_MONTH_IN_MS
  35. if (options.BATCH_RANGE_END) {
  36. BATCH_RANGE_END = new ObjectId(options.BATCH_RANGE_END)
  37. } else {
  38. if (BATCH_DESCENDING) {
  39. BATCH_RANGE_END = ID_EDGE_PAST
  40. } else {
  41. BATCH_RANGE_END = ID_EDGE_FUTURE
  42. }
  43. }
  44. }
  45. async function getNextBatch({
  46. collection,
  47. query,
  48. start,
  49. end,
  50. projection,
  51. findOptions,
  52. }) {
  53. if (BATCH_DESCENDING) {
  54. query._id = {
  55. $gt: end,
  56. $lt: start,
  57. }
  58. } else {
  59. query._id = {
  60. $gt: start,
  61. $lt: end,
  62. }
  63. }
  64. return await collection
  65. .find(query, findOptions)
  66. .project(projection)
  67. .sort({ _id: BATCH_DESCENDING ? -1 : 1 })
  68. .limit(BATCH_SIZE)
  69. .toArray()
  70. }
  71. async function performUpdate(collection, nextBatch, update) {
  72. return collection.updateMany(
  73. { _id: { $in: nextBatch.map(entry => entry._id) } },
  74. update
  75. )
  76. }
  77. function objectIdFromMs(ms) {
  78. return ObjectId.createFromTime(ms / 1000)
  79. }
  80. function getMsFromObjectId(id) {
  81. return id.getTimestamp().getTime()
  82. }
  83. function getNextEnd(start) {
  84. let end
  85. if (BATCH_DESCENDING) {
  86. end = objectIdFromMs(getMsFromObjectId(start) - BATCH_MAX_TIME_SPAN_IN_MS)
  87. if (getMsFromObjectId(end) <= getMsFromObjectId(BATCH_RANGE_END)) {
  88. end = BATCH_RANGE_END
  89. }
  90. } else {
  91. end = objectIdFromMs(getMsFromObjectId(start) + BATCH_MAX_TIME_SPAN_IN_MS)
  92. if (getMsFromObjectId(end) >= getMsFromObjectId(BATCH_RANGE_END)) {
  93. end = BATCH_RANGE_END
  94. }
  95. }
  96. return end
  97. }
  98. async function getIdEdgePast(collection) {
  99. const [first] = await collection
  100. .find({})
  101. .project({ _id: 1 })
  102. .sort({ _id: 1 })
  103. .limit(1)
  104. .toArray()
  105. if (!first) return null
  106. // Go 1s further into the past in order to include the first entry via
  107. // first._id > ID_EDGE_PAST
  108. return objectIdFromMs(Math.max(0, getMsFromObjectId(first._id) - 1000))
  109. }
  110. async function batchedUpdate(
  111. collectionName,
  112. query,
  113. update,
  114. projection,
  115. findOptions,
  116. batchedUpdateOptions
  117. ) {
  118. await waitForDb()
  119. const collection = db[collectionName]
  120. ID_EDGE_PAST = await getIdEdgePast(collection)
  121. if (!ID_EDGE_PAST) {
  122. console.warn(`The collection ${collectionName} appears to be empty.`)
  123. return 0
  124. }
  125. refreshGlobalOptionsForBatchedUpdate(batchedUpdateOptions)
  126. findOptions = findOptions || {}
  127. findOptions.readPreference = READ_PREFERENCE_SECONDARY
  128. projection = projection || { _id: 1 }
  129. let nextBatch
  130. let updated = 0
  131. let start = BATCH_RANGE_START
  132. while (start !== BATCH_RANGE_END) {
  133. let end = getNextEnd(start)
  134. nextBatch = await getNextBatch({
  135. collection,
  136. query,
  137. start,
  138. end,
  139. projection,
  140. findOptions,
  141. })
  142. if (nextBatch.length > 0) {
  143. end = nextBatch[nextBatch.length - 1]._id
  144. updated += nextBatch.length
  145. if (VERBOSE_LOGGING) {
  146. console.log(
  147. `Running update on batch with ids ${JSON.stringify(
  148. nextBatch.map(entry => entry._id)
  149. )}`
  150. )
  151. } else {
  152. console.error(`Running update on batch ending ${end}`)
  153. }
  154. if (typeof update === 'function') {
  155. await update(nextBatch)
  156. } else {
  157. await performUpdate(collection, nextBatch, update)
  158. }
  159. }
  160. console.error(`Completed batch ending ${end}`)
  161. start = end
  162. }
  163. return updated
  164. }
  165. function batchedUpdateWithResultHandling(
  166. collection,
  167. query,
  168. update,
  169. projection,
  170. options
  171. ) {
  172. batchedUpdate(collection, query, update, projection, options)
  173. .then(processed => {
  174. console.error({ processed })
  175. process.exit(0)
  176. })
  177. .catch(error => {
  178. console.error({ error })
  179. process.exit(1)
  180. })
  181. }
  182. export default {
  183. batchedUpdate,
  184. batchedUpdateWithResultHandling,
  185. }