ProjectPersistenceManager.js 8.5 KB

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