ProjectPersistenceManager.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. // TODO: This file was created by bulk-decaffeinate.
  2. // Fix any style issues and re-enable lint.
  3. /*
  4. * decaffeinate suggestions:
  5. * DS101: Remove unnecessary use of Array.from
  6. * DS102: Remove unnecessary code created because of implicit returns
  7. * DS207: Consider shorter variations of null checks
  8. * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  9. */
  10. import UrlCache from './UrlCache.js'
  11. import CompileManager from './CompileManager.js'
  12. import async from 'async'
  13. import logger from '@overleaf/logger'
  14. import Metrics from '@overleaf/metrics'
  15. import Settings from '@overleaf/settings'
  16. import { callbackify } from 'node:util'
  17. import Path from 'node:path'
  18. import fs from 'node:fs'
  19. import * as HistoryResourceWriter from './HistoryResourceWriter.js'
  20. let ProjectPersistenceManager
  21. const oneDay = 24 * 60 * 60 * 1000
  22. // projectId -> timestamp mapping.
  23. const LAST_ACCESS = new Map()
  24. let ANY_DISK_LOW = false
  25. let ANY_DISK_CRITICAL_LOW = false
  26. async function collectDiskStats() {
  27. const paths = [
  28. Settings.path.compilesDir,
  29. Settings.path.outputDir,
  30. Settings.path.clsiCacheDir,
  31. ]
  32. const diskStats = {}
  33. let anyDiskLow = false
  34. let anyDiskCriticalLow = false
  35. for (const path of paths) {
  36. try {
  37. const { blocks, bavail, bsize } = await fs.promises.statfs(path)
  38. const stats = {
  39. // Warning: these values will be wrong by a factor in Docker-for-Mac.
  40. // See https://github.com/docker/for-mac/issues/2136
  41. total: blocks * bsize, // Total size of the file system in bytes
  42. available: bavail * bsize, // Free space available to unprivileged users.
  43. }
  44. const diskAvailablePercent = (stats.available / stats.total) * 100
  45. Metrics.gauge('disk_available_percent', diskAvailablePercent, 1, {
  46. path,
  47. })
  48. const lowDisk = diskAvailablePercent < 10
  49. diskStats[path] = { stats, lowDisk }
  50. const criticalLowDisk = diskAvailablePercent < 3
  51. anyDiskLow = anyDiskLow || lowDisk
  52. anyDiskCriticalLow = anyDiskCriticalLow || criticalLowDisk
  53. } catch (err) {
  54. logger.err({ err, path }, 'error getting disk usage')
  55. }
  56. }
  57. ANY_DISK_LOW = anyDiskLow
  58. ANY_DISK_CRITICAL_LOW = anyDiskCriticalLow
  59. return diskStats
  60. }
  61. async function refreshExpiryTimeout() {
  62. for (const [path, { stats, lowDisk }] of Object.entries(
  63. await collectDiskStats()
  64. )) {
  65. const lowerExpiry = ProjectPersistenceManager.EXPIRY_TIMEOUT * 0.9
  66. if (lowDisk && Settings.project_cache_length_ms / 2 < lowerExpiry) {
  67. logger.warn(
  68. {
  69. path,
  70. stats,
  71. newExpiryTimeoutInDays: (lowerExpiry / oneDay).toFixed(2),
  72. },
  73. 'disk running low on space, modifying EXPIRY_TIMEOUT'
  74. )
  75. ProjectPersistenceManager.EXPIRY_TIMEOUT = lowerExpiry
  76. break
  77. }
  78. }
  79. Metrics.gauge(
  80. 'project_persistence_expiry_timeout',
  81. ProjectPersistenceManager.EXPIRY_TIMEOUT
  82. )
  83. }
  84. export default ProjectPersistenceManager = {
  85. EXPIRY_TIMEOUT: Settings.project_cache_length_ms || oneDay * 2.5,
  86. isAnyDiskLow() {
  87. return ANY_DISK_LOW
  88. },
  89. isAnyDiskCriticalLow() {
  90. return ANY_DISK_CRITICAL_LOW
  91. },
  92. promises: {
  93. refreshExpiryTimeout,
  94. },
  95. refreshExpiryTimeout: callbackify(refreshExpiryTimeout),
  96. init() {
  97. fs.readdir(Settings.path.compilesDir, (err, dirs) => {
  98. if (err) {
  99. logger.warn({ err }, 'cannot get project listing')
  100. dirs = []
  101. }
  102. async.eachLimit(
  103. dirs,
  104. 10,
  105. (projectAndUserId, cb) => {
  106. const compileDir = Path.join(
  107. Settings.path.compilesDir,
  108. projectAndUserId
  109. )
  110. const projectId = projectAndUserId.slice(0, 24)
  111. fs.stat(compileDir, (err, stats) => {
  112. if (err) {
  113. // Schedule for immediate cleanup
  114. LAST_ACCESS.set(projectId, 0)
  115. } else {
  116. // Cleanup eventually.
  117. LAST_ACCESS.set(projectId, stats.mtime.getTime())
  118. }
  119. cb()
  120. })
  121. },
  122. () => {
  123. setInterval(
  124. () => {
  125. ProjectPersistenceManager.refreshExpiryTimeout(() => {
  126. ProjectPersistenceManager.clearExpiredProjects(err => {
  127. if (err) {
  128. logger.error({ err }, 'clearing expired projects failed')
  129. }
  130. })
  131. })
  132. },
  133. 10 * 60 * 1000
  134. )
  135. }
  136. )
  137. })
  138. // Collect disk stats frequently to have them ready the next time /metrics is scraped (60s +- jitter) or every 5th scrape of the load agent (3s +- jitter).
  139. setInterval(() => {
  140. collectDiskStats().catch(err => {
  141. logger.err({ err }, 'low level error collecting disk stats')
  142. })
  143. }, 15_000)
  144. },
  145. markProjectAsJustAccessed(projectId, callback) {
  146. LAST_ACCESS.set(projectId, Date.now())
  147. callback()
  148. },
  149. clearExpiredProjects(callback) {
  150. if (callback == null) {
  151. callback = function () {}
  152. }
  153. return ProjectPersistenceManager._findExpiredProjectIds(
  154. function (error, projectIds) {
  155. if (error != null) {
  156. return callback(error)
  157. }
  158. logger.debug({ projectIds }, 'clearing expired projects')
  159. const jobs = Array.from(projectIds || []).map(projectId =>
  160. (
  161. projectId => callback =>
  162. ProjectPersistenceManager.clearProjectFromCache(
  163. projectId,
  164. { reason: 'expired' },
  165. function (err) {
  166. if (err != null) {
  167. logger.error({ err, projectId }, 'error clearing project')
  168. }
  169. return callback()
  170. }
  171. )
  172. )(projectId)
  173. )
  174. return async.series(jobs, function (error) {
  175. if (error != null) {
  176. return callback(error)
  177. }
  178. return CompileManager.clearExpiredProjects(
  179. ProjectPersistenceManager.EXPIRY_TIMEOUT,
  180. error => callback(error)
  181. )
  182. })
  183. }
  184. )
  185. }, // ignore any errors from deleting directories
  186. clearProject(projectId, userId, callback) {
  187. if (callback == null) {
  188. callback = function () {}
  189. }
  190. logger.debug({ projectId, userId }, 'clearing project for user')
  191. return CompileManager.clearProject(projectId, userId, function (error) {
  192. if (error) return callback(error)
  193. HistoryResourceWriter.clearCacheCb(projectId, userId, error => {
  194. if (error) return callback(error)
  195. ProjectPersistenceManager.clearProjectFromCache(
  196. projectId,
  197. { reason: 'cleared' },
  198. callback
  199. )
  200. })
  201. })
  202. },
  203. clearProjectFromCache(projectId, options, callback) {
  204. if (callback == null) {
  205. callback = function () {}
  206. }
  207. logger.debug({ projectId }, 'clearing project from cache')
  208. return UrlCache.clearProject(projectId, options, function (error) {
  209. if (error != null) {
  210. logger.err({ error, projectId }, 'error clearing project from cache')
  211. return callback(error)
  212. }
  213. return ProjectPersistenceManager._clearProjectFromDatabase(
  214. projectId,
  215. function (error) {
  216. if (error != null) {
  217. logger.err(
  218. { error, projectId },
  219. 'error clearing project from database'
  220. )
  221. }
  222. return callback(error)
  223. }
  224. )
  225. })
  226. },
  227. _clearProjectFromDatabase(projectId, callback) {
  228. LAST_ACCESS.delete(projectId)
  229. callback()
  230. },
  231. _findExpiredProjectIds(callback) {
  232. const expiredFrom = Date.now() - ProjectPersistenceManager.EXPIRY_TIMEOUT
  233. const expiredProjectsIds = []
  234. for (const [projectId, lastAccess] of LAST_ACCESS.entries()) {
  235. if (lastAccess < expiredFrom) {
  236. expiredProjectsIds.push(projectId)
  237. }
  238. }
  239. // ^ may be a fairly busy loop, continue detached.
  240. setTimeout(() => callback(null, expiredProjectsIds), 0)
  241. },
  242. }
  243. logger.debug(
  244. { EXPIRY_TIMEOUT: ProjectPersistenceManager.EXPIRY_TIMEOUT },
  245. 'project assets kept timeout'
  246. )