project_notifications.mts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import Settings from '@overleaf/settings'
  2. import logger from '@overleaf/logger'
  3. import { createClient } from '@overleaf/redis-wrapper'
  4. import mongodb from '../app/js/mongodb.js'
  5. import Queue from 'bull'
  6. import minimist from 'minimist'
  7. logger.logger.level('fatal')
  8. const argv = minimist(process.argv.slice(2), {
  9. boolean: ['dry-run', 'help'],
  10. alias: {
  11. n: 'dry-run',
  12. h: 'help',
  13. },
  14. default: {
  15. 'dry-run': false,
  16. help: false,
  17. },
  18. })
  19. if (argv.help) {
  20. console.log(`
  21. project_notifications.mts - Queue project update notifications
  22. This script scans Redis for projects that have pending notification timestamps and queues
  23. them for notification. It's used to notify project collaborators when changes have been
  24. made to a project. Only projects with collaborators are processed.
  25. Usage: node scripts/project_notifications.mts [options]
  26. Options:
  27. -n, --dry-run Show what would be done without making changes
  28. -h, --help Show this help message
  29. Examples:
  30. # Dry run to see what would be notified
  31. node scripts/project_notifications.mts --dry-run
  32. # Actually queue the notifications
  33. node scripts/project_notifications.mts
  34. `)
  35. process.exit(0)
  36. }
  37. const dryRun = argv['dry-run']
  38. const { db, ObjectId, READ_PREFERENCE_SECONDARY } = mongodb
  39. const docUpdaterKeys = Settings.redis.documentupdater.key_schema
  40. const redisClient = createClient(Settings.redis.documentupdater)
  41. // Define Lua script to safely delete the key only if it matches expected value
  42. redisClient.defineCommand('deleteProjectNotificationTimestamp', {
  43. numberOfKeys: 1,
  44. lua: `
  45. local projectNotificationKey = KEYS[1]
  46. local expectedTimestamp = ARGV[1]
  47. local currentTimestamp = redis.call('GET', projectNotificationKey)
  48. if currentTimestamp and currentTimestamp == expectedTimestamp then
  49. redis.call('DEL', projectNotificationKey)
  50. return 1
  51. end
  52. return 0
  53. `,
  54. })
  55. const queueRedisConfig = {
  56. host: process.env.QUEUES_REDIS_HOST || '127.0.0.1',
  57. port: parseInt(process.env.QUEUES_REDIS_PORT || '6379', 10),
  58. password: process.env.QUEUES_REDIS_PASSWORD,
  59. }
  60. const QUEUE_NAME = 'project-notification'
  61. const PROGRESS_LOG_INTERVAL_MS = 15_000
  62. const projectNotificationQueue = new Queue(QUEUE_NAME, {
  63. redis: queueRedisConfig,
  64. defaultJobOptions: {
  65. removeOnComplete: true,
  66. removeOnFail: { count: 50000, age: 3600 },
  67. attempts: 3,
  68. backoff: {
  69. type: 'exponential',
  70. delay: 3000,
  71. },
  72. },
  73. })
  74. async function main() {
  75. console.time('total')
  76. if (dryRun) {
  77. console.log('[DRY RUN MODE] - No changes will be made')
  78. }
  79. console.log('Scanning for projects that need to be notified...')
  80. const { projects, stats } = await getProjectsToNotify()
  81. console.log(
  82. `Scan complete: scanned=${stats.scanned}, matched=${stats.matched}, skippedNoCollaborators=${stats.skippedNoCollaborators}, skippedNoTimestamp=${stats.skippedNoTimestamp}, skippedInvalidTimestamp=${stats.skippedInvalidTimestamp}, skippedNoProjectId=${stats.skippedNoProjectId}`
  83. )
  84. console.log(
  85. `Collaborator lookups: cacheHitWithCollaborators=${stats.collaboratorCacheHitWithCollaborators}, cacheHitNoCollaborators=${stats.collaboratorCacheHitNoCollaborators}, cacheMissWithCollaborators=${stats.collaboratorCacheMissWithCollaborators}, cacheMissNoCollaborators=${stats.collaboratorCacheMissNoCollaborators}`
  86. )
  87. if (dryRun) {
  88. console.log('\n[DRY RUN] Projects that would be queued:')
  89. for (const { projectId, timestamp } of projects) {
  90. const date = new Date(parseInt(timestamp, 10))
  91. console.log(
  92. ` ${projectId}: ${timestamp} (${date.toISOString()}) - would be queued`
  93. )
  94. }
  95. console.timeEnd('total')
  96. return
  97. }
  98. console.log('Waiting for queue to be ready...')
  99. await projectNotificationQueue.isReady()
  100. console.log('Queue is ready.')
  101. let queued = 0
  102. let failed = 0
  103. let deleteMismatches = 0
  104. let lastProgressLog = Date.now()
  105. for (const { projectId, timestamp } of projects) {
  106. const numericTimestamp = parseInt(timestamp, 10)
  107. try {
  108. await projectNotificationQueue.add(
  109. { projectId, timestamp: numericTimestamp },
  110. {
  111. jobId: projectId,
  112. delay: 1000,
  113. }
  114. )
  115. const deleted = await deleteProjectNotificationTimestamp(
  116. projectId,
  117. timestamp
  118. )
  119. if (!deleted) {
  120. deleteMismatches++
  121. }
  122. queued++
  123. } catch (err) {
  124. failed++
  125. console.error(
  126. `Error scheduling notification for project ${projectId}:`,
  127. err
  128. )
  129. }
  130. if (Date.now() - lastProgressLog >= PROGRESS_LOG_INTERVAL_MS) {
  131. console.log(
  132. `Queue progress: queued=${queued}, failed=${failed} of ${projects.length}`
  133. )
  134. lastProgressLog = Date.now()
  135. }
  136. }
  137. console.log(
  138. `Queue complete: queued=${queued}, failed=${failed}, deleteMismatches=${deleteMismatches}`
  139. )
  140. console.timeEnd('total')
  141. }
  142. /**
  143. * Extract project ID from a ProjectNotificationTimestamp key
  144. * Key format: ProjectNotificationTimestamp:{project_id}
  145. */
  146. function extractProjectId(key: string): string | undefined {
  147. const matches = key.match(/ProjectNotificationTimestamp:\{(.*?)\}/)
  148. if (matches) {
  149. return matches[1]
  150. }
  151. }
  152. type ProjectNotification = {
  153. projectId: string
  154. timestamp: string
  155. }
  156. /**
  157. * Check if a project has any collaborators (excluding owner)
  158. * Uses Redis caching with 1-2 hour randomized expiration to avoid repeated MongoDB queries
  159. */
  160. async function projectHasCollaborators(
  161. projectId: string,
  162. stats: NotificationStats
  163. ): Promise<boolean> {
  164. // Check Redis cache first
  165. const cacheKey = `ProjectHasCollaborators:{${projectId}}`
  166. const cachedResult = await redisClient.get(cacheKey)
  167. if (cachedResult !== null) {
  168. if (cachedResult === '1') {
  169. stats.collaboratorCacheHitWithCollaborators++
  170. } else {
  171. stats.collaboratorCacheHitNoCollaborators++
  172. }
  173. return cachedResult === '1'
  174. }
  175. // Cache miss - query MongoDB
  176. const hasCollaborators = await db.projects.findOne(
  177. {
  178. _id: new ObjectId(projectId),
  179. $or: [
  180. { 'collaberator_refs.0': { $exists: true } }, // check that first element in array exists
  181. { 'readOnly_refs.0': { $exists: true } },
  182. { 'reviewer_refs.0': { $exists: true } },
  183. { 'tokenAccessReadAndWrite_refs.0': { $exists: true } },
  184. { 'tokenAccessReadOnly_refs.0': { $exists: true } },
  185. ],
  186. },
  187. { projection: { _id: 1 }, readPreference: READ_PREFERENCE_SECONDARY }
  188. )
  189. // Use random TTL between 1-2 hours (3600-7200 seconds) to smooth out cache expiration
  190. const randomTTL = 3600 + Math.floor(Math.random() * 3600)
  191. if (hasCollaborators === null) {
  192. // Cache negative result (no collaborators or project not found)
  193. stats.collaboratorCacheMissNoCollaborators++
  194. await redisClient.setex(cacheKey, randomTTL, '0')
  195. return false
  196. }
  197. // Cache the result in Redis
  198. stats.collaboratorCacheMissWithCollaborators++
  199. await redisClient.setex(cacheKey, randomTTL, hasCollaborators ? '1' : '0')
  200. return true
  201. }
  202. /**
  203. * Scan Redis for all projectNotificationTimestamp keys and return list of projects with timestamps
  204. */
  205. type NotificationStats = {
  206. scanned: number
  207. matched: number
  208. skippedNoCollaborators: number
  209. skippedNoTimestamp: number
  210. skippedInvalidTimestamp: number
  211. skippedNoProjectId: number
  212. collaboratorCacheHitWithCollaborators: number
  213. collaboratorCacheHitNoCollaborators: number
  214. collaboratorCacheMissWithCollaborators: number
  215. collaboratorCacheMissNoCollaborators: number
  216. }
  217. async function getProjectsToNotify(): Promise<{
  218. projects: ProjectNotification[]
  219. stats: NotificationStats
  220. }> {
  221. const nodes = (typeof redisClient.nodes === 'function'
  222. ? redisClient.nodes('master')
  223. : undefined) || [redisClient]
  224. const projects: ProjectNotification[] = []
  225. const stats: NotificationStats = {
  226. scanned: 0,
  227. matched: 0,
  228. skippedNoCollaborators: 0,
  229. skippedNoTimestamp: 0,
  230. skippedInvalidTimestamp: 0,
  231. skippedNoProjectId: 0,
  232. collaboratorCacheHitWithCollaborators: 0,
  233. collaboratorCacheHitNoCollaborators: 0,
  234. collaboratorCacheMissWithCollaborators: 0,
  235. collaboratorCacheMissNoCollaborators: 0,
  236. }
  237. let lastProgressLog = Date.now()
  238. console.time('redis-scan')
  239. try {
  240. for (const node of nodes) {
  241. const stream = node.scanStream({
  242. match: docUpdaterKeys.projectNotificationTimestamp({ project_id: '*' }),
  243. count: 1000,
  244. })
  245. for await (const keys of stream) {
  246. if (keys.length === 0) {
  247. continue
  248. }
  249. const timestamps = await redisClient.mget(keys)
  250. for (const [index, key] of keys.entries()) {
  251. stats.scanned++
  252. const projectId = extractProjectId(key as string)
  253. const timestamp = timestamps[index]
  254. if (!projectId) {
  255. stats.skippedNoProjectId++
  256. console.error('Could not extract project ID from key:', key)
  257. continue
  258. }
  259. if (!timestamp) {
  260. stats.skippedNoTimestamp++
  261. console.error(`No timestamp found for key: ${key}`)
  262. continue
  263. }
  264. const hasCollaborators = await projectHasCollaborators(
  265. projectId,
  266. stats
  267. )
  268. if (!hasCollaborators) {
  269. stats.skippedNoCollaborators++
  270. continue
  271. }
  272. const numericTimestamp = parseInt(timestamp, 10)
  273. if (Number.isNaN(numericTimestamp)) {
  274. stats.skippedInvalidTimestamp++
  275. console.error(
  276. `Non-numeric timestamp for project ${projectId}: ${timestamp}`
  277. )
  278. continue
  279. }
  280. stats.matched++
  281. projects.push({ projectId, timestamp })
  282. }
  283. if (Date.now() - lastProgressLog >= PROGRESS_LOG_INTERVAL_MS) {
  284. console.log(
  285. `Scan progress: scanned=${stats.scanned}, matched=${stats.matched}, skipped=${stats.scanned - stats.matched}`
  286. )
  287. lastProgressLog = Date.now()
  288. }
  289. }
  290. }
  291. } finally {
  292. console.timeEnd('redis-scan')
  293. }
  294. return { projects, stats }
  295. }
  296. /**
  297. * Delete the projectNotificationTimestamp key for a project
  298. * Only deletes if the timestamp matches the expected value to avoid race conditions
  299. */
  300. async function deleteProjectNotificationTimestamp(
  301. projectId: string,
  302. expectedTimestamp: string
  303. ): Promise<boolean> {
  304. const key = docUpdaterKeys.projectNotificationTimestamp({
  305. project_id: projectId,
  306. })
  307. const deleted = await redisClient.deleteProjectNotificationTimestamp(
  308. key,
  309. expectedTimestamp
  310. )
  311. return deleted === 1
  312. }
  313. main()
  314. .then(() => {
  315. process.exit(0)
  316. })
  317. .catch(error => {
  318. console.error('Error scanning for project notifications:', error)
  319. console.timeEnd('total')
  320. process.exit(1)
  321. })
  322. .finally(async () => {
  323. // Close the Bull queue connection
  324. await projectNotificationQueue.close()
  325. })