project_notifications.mts 12 KB

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