project_notifications.mts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  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.ts - 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: project_notifications.ts [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. project_notifications.ts --dry-run
  32. # Actually queue the notifications
  33. project_notifications.ts
  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 projectNotificationQueue = new Queue(QUEUE_NAME, {
  62. redis: queueRedisConfig,
  63. defaultJobOptions: {
  64. removeOnComplete: true,
  65. removeOnFail: { count: 50000, age: 3600 },
  66. attempts: 3,
  67. backoff: {
  68. type: 'exponential',
  69. delay: 3000,
  70. },
  71. },
  72. })
  73. async function main() {
  74. if (dryRun) {
  75. console.log('[DRY RUN MODE] - No changes will be made')
  76. }
  77. console.log('Scanning for projects that need to be notified...')
  78. const projects = await getProjectsToNotify()
  79. console.log(`\nFound ${projects.length} project(s) that need to be notified`)
  80. if (dryRun) {
  81. console.log('\n[DRY RUN] Projects that would be queued:')
  82. for (const { projectId, timestamp } of projects) {
  83. const date = new Date(parseInt(timestamp))
  84. console.log(
  85. ` ${projectId}: ${timestamp} (${date.toISOString()}) - would be queued`
  86. )
  87. }
  88. return
  89. }
  90. console.log('Waiting for queue to be ready...')
  91. await projectNotificationQueue.isReady()
  92. console.log('Queue is ready.')
  93. for (const { projectId, timestamp } of projects) {
  94. try {
  95. await projectNotificationQueue.add(
  96. { projectId, timestamp },
  97. {
  98. jobId: projectId,
  99. },
  100. {
  101. delay: 1000,
  102. }
  103. )
  104. // Delete the timestamp key after scheduling (only if it still matches)
  105. await deleteProjectNotificationTimestamp(projectId, timestamp)
  106. const date = new Date(parseInt(timestamp))
  107. console.log(
  108. ` ${projectId}: ${timestamp} (${date.toISOString()}) - queued`
  109. )
  110. } catch (err) {
  111. console.error(
  112. `Error scheduling notification for project ${projectId}:`,
  113. err
  114. )
  115. }
  116. }
  117. }
  118. /**
  119. * Extract project ID from a ProjectNotificationTimestamp key
  120. * Key format: ProjectNotificationTimestamp:{project_id}
  121. */
  122. function extractProjectId(key: string): string | undefined {
  123. const matches = key.match(/ProjectNotificationTimestamp:\{(.*?)\}/)
  124. if (matches) {
  125. return matches[1]
  126. }
  127. }
  128. type ProjectNotification = {
  129. projectId: string
  130. timestamp: string
  131. }
  132. /**
  133. * Check if a project has any collaborators (excluding owner)
  134. * Uses Redis caching with 1 hour expiration to avoid repeated MongoDB queries
  135. */
  136. async function projectHasCollaborators(projectId: string): Promise<boolean> {
  137. // Check Redis cache first
  138. const cacheKey = `ProjectHasCollaborators:{${projectId}}`
  139. const cachedResult = await redisClient.get(cacheKey)
  140. if (cachedResult !== null) {
  141. return cachedResult === '1'
  142. }
  143. // Cache miss - query MongoDB
  144. const hasCollaborators = await db.projects.findOne(
  145. {
  146. _id: new ObjectId(projectId),
  147. $or: [
  148. { 'collaberator_refs.0': { $exists: true } }, // check that first element in array exists
  149. { 'readOnly_refs.0': { $exists: true } },
  150. { 'reviewer_refs.0': { $exists: true } },
  151. { 'tokenAccessReadAndWrite_refs.0': { $exists: true } },
  152. { 'tokenAccessReadOnly_refs.0': { $exists: true } },
  153. ],
  154. },
  155. { projection: { _id: 1 }, readPreference: READ_PREFERENCE_SECONDARY }
  156. )
  157. // Use random TTL between 1-2 hours (3600-7200 seconds) to smooth out cache expiration
  158. const randomTTL = 3600 + Math.floor(Math.random() * 3600)
  159. if (hasCollaborators === null) {
  160. // Cache false result for non-existent projects
  161. await redisClient.setex(cacheKey, randomTTL, '0')
  162. return false
  163. }
  164. // Cache the result in Redis
  165. await redisClient.setex(cacheKey, randomTTL, hasCollaborators ? '1' : '0')
  166. return true
  167. }
  168. /**
  169. * Scan Redis for all projectNotificationTimestamp keys and return list of projects with timestamps
  170. */
  171. async function getProjectsToNotify(): Promise<ProjectNotification[]> {
  172. const nodes = (typeof redisClient.nodes === 'function'
  173. ? redisClient.nodes('master')
  174. : undefined) || [redisClient]
  175. const projects: ProjectNotification[] = []
  176. for (const node of nodes) {
  177. console.log('Scanning Redis node for projectNotificationTimestamp keys...')
  178. // Scan for all ProjectNotificationTimestamp keys
  179. const stream = node.scanStream({
  180. match: docUpdaterKeys.projectNotificationTimestamp({ project_id: '*' }),
  181. })
  182. for await (const keys of stream) {
  183. if (keys.length === 0) {
  184. continue
  185. }
  186. console.log(`Found batch of ${keys.length} keys`)
  187. // Get timestamps for all keys in this batch
  188. const timestamps = await redisClient.mget(keys)
  189. // Extract project IDs and pair with timestamps, checking for collaborators
  190. for (const [index, key] of keys.entries()) {
  191. const projectId = extractProjectId(key as string)
  192. const timestamp = timestamps[index]
  193. if (!projectId) {
  194. console.log('Could not extract project ID from key:', key)
  195. continue
  196. }
  197. if (!timestamp) {
  198. console.log('No timestamp found for key:', key)
  199. continue
  200. }
  201. // Check if project has collaborators before adding to list
  202. const hasCollaborators = await projectHasCollaborators(projectId)
  203. if (!hasCollaborators) {
  204. console.log(`Skipping project ${projectId} - no collaborators`)
  205. continue
  206. }
  207. projects.push({ projectId, timestamp })
  208. }
  209. }
  210. }
  211. return projects
  212. }
  213. /**
  214. * Delete the projectNotificationTimestamp key for a project
  215. * Only deletes if the timestamp matches the expected value to avoid race conditions
  216. */
  217. async function deleteProjectNotificationTimestamp(
  218. projectId: string,
  219. expectedTimestamp: string
  220. ): Promise<void> {
  221. const key = docUpdaterKeys.projectNotificationTimestamp({
  222. project_id: projectId,
  223. })
  224. const deleted = await redisClient.deleteProjectNotificationTimestamp(
  225. key,
  226. expectedTimestamp
  227. )
  228. if (deleted === 1) {
  229. console.log(`Deleted timestamp key for project ${projectId}`)
  230. } else {
  231. console.log(
  232. `Timestamp key for project ${projectId} was not deleted (value mismatch or key not found)`
  233. )
  234. }
  235. }
  236. main()
  237. .then(() => {
  238. console.log('\nDone.')
  239. process.exit(0)
  240. })
  241. .catch(error => {
  242. console.error('Error scanning for project notifications:', error)
  243. process.exit(1)
  244. })
  245. .finally(async () => {
  246. // Close the Bull queue connection
  247. await projectNotificationQueue.close()
  248. })