|
@@ -0,0 +1,191 @@
|
|
|
|
|
+#!/usr/bin/env node
|
|
|
|
|
+
|
|
|
|
|
+import Settings from '@overleaf/settings'
|
|
|
|
|
+import minimist from 'minimist'
|
|
|
|
|
+import logger from '@overleaf/logger'
|
|
|
|
|
+import PQueue from 'p-queue'
|
|
|
|
|
+import * as RedisManager from '../app/js/RedisManager.js'
|
|
|
|
|
+import * as ErrorRecorder from '../app/js/ErrorRecorder.js'
|
|
|
|
|
+
|
|
|
|
|
+logger.logger.level('fatal')
|
|
|
|
|
+
|
|
|
|
|
+function usage() {
|
|
|
|
|
+ console.log(`
|
|
|
|
|
+Usage: flush_old.js [options]
|
|
|
|
|
+
|
|
|
|
|
+Options:
|
|
|
|
|
+ -b, --batch-size <size> Number of projects to process in each batch (default: 100)
|
|
|
|
|
+ -a, --max-age <seconds> Maximum age of projects to keep (default: 3600)
|
|
|
|
|
+ -i, --interval <seconds> Interval to spread the processing over (default: 300)
|
|
|
|
|
+ -c, --concurrency <number> Number of concurrent jobs (default: 10)
|
|
|
|
|
+ -u, --buffer <seconds> Buffer time in seconds to reserve at end (default: 15)
|
|
|
|
|
+ -n, --dry-run Show what would be done without making changes
|
|
|
|
|
+ -h, --help Show this help message
|
|
|
|
|
+
|
|
|
|
|
+Examples:
|
|
|
|
|
+ # Flush projects older than 24 hours with 5 concurrent jobs
|
|
|
|
|
+ flush_old.js --batch-size 100 --max-age 86400 -c 5
|
|
|
|
|
+
|
|
|
|
|
+ # Dry run to see what would be flushed
|
|
|
|
|
+ flush_old.js --max-age 3600 --dry-run
|
|
|
|
|
+`)
|
|
|
|
|
+ process.exit(0)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const argv = minimist(process.argv.slice(2), {
|
|
|
|
|
+ boolean: ['dry-run', 'help'],
|
|
|
|
|
+ alias: {
|
|
|
|
|
+ b: 'batch-size',
|
|
|
|
|
+ a: 'max-age',
|
|
|
|
|
+ i: 'interval',
|
|
|
|
|
+ c: 'concurrency',
|
|
|
|
|
+ n: 'dry-run',
|
|
|
|
|
+ u: 'buffer',
|
|
|
|
|
+ h: 'help',
|
|
|
|
|
+ },
|
|
|
|
|
+ default: {
|
|
|
|
|
+ 'batch-size': 100,
|
|
|
|
|
+ 'max-age': 3600,
|
|
|
|
|
+ interval: 300,
|
|
|
|
|
+ concurrency: 10,
|
|
|
|
|
+ 'dry-run': false,
|
|
|
|
|
+ buffer: 15,
|
|
|
|
|
+ help: false,
|
|
|
|
|
+ },
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+if (argv.help || process.argv.length === 2) {
|
|
|
|
|
+ usage()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const batchSize = parseInt(argv['batch-size'], 10)
|
|
|
|
|
+const maxAge = argv['max-age'] ? parseInt(argv['max-age'], 10) : null
|
|
|
|
|
+const interval = parseInt(argv.interval, 10) || 300
|
|
|
|
|
+const concurrency = parseInt(argv.concurrency, 10) || 10
|
|
|
|
|
+const bufferTime = parseInt(argv.buffer, 10) || 15
|
|
|
|
|
+const dryRun = argv['dry-run']
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Generator function that yields batches of items from an array
|
|
|
|
|
+ * @param {Array} array - The array to batch
|
|
|
|
|
+ * @param {number} size - The size of each batch
|
|
|
|
|
+ * @yields {Array} A batch of items
|
|
|
|
|
+ */
|
|
|
|
|
+function* getBatches(array, size) {
|
|
|
|
|
+ for (let i = 0; i < array.length; i += size) {
|
|
|
|
|
+ yield array.slice(i, i + size)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+let flushCount = 0
|
|
|
|
|
+
|
|
|
|
|
+async function flushProject({ projectId, timestamp }) {
|
|
|
|
|
+ const url = `${Settings.apis.project_history.url}/project/${projectId}/flush`
|
|
|
|
|
+ if (dryRun) {
|
|
|
|
|
+ console.log(`[DRY RUN] would flush project ${projectId}`)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ const response = await fetch(url, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ })
|
|
|
|
|
+ flushCount++
|
|
|
|
|
+ if (flushCount % 100 === 0) {
|
|
|
|
|
+ console.log('flushed', flushCount, 'projects, up to', timestamp)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ throw new Error(`failed to flush project ${projectId}`)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const SCRIPT_START_TIME = Date.now() // current time in milliseconds from start of script
|
|
|
|
|
+
|
|
|
|
|
+function olderThan(maxAge, timestamp) {
|
|
|
|
|
+ const age = (SCRIPT_START_TIME - timestamp) / 1000
|
|
|
|
|
+ return age > maxAge
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function main() {
|
|
|
|
|
+ const projectIds = await RedisManager.promises.getProjectIdsWithHistoryOps()
|
|
|
|
|
+ const failedProjects = await ErrorRecorder.promises.getFailedProjects()
|
|
|
|
|
+ const failedProjectIds = new Set(failedProjects.map(p => p.project_id))
|
|
|
|
|
+
|
|
|
|
|
+ const projectIdsToProcess = projectIds.filter(p => !failedProjectIds.has(p))
|
|
|
|
|
+ console.log('number of projects with history ops', projectIds.length)
|
|
|
|
|
+ console.log(
|
|
|
|
|
+ 'number of failed projects to exclude',
|
|
|
|
|
+ projectIds.length - projectIdsToProcess.length
|
|
|
|
|
+ )
|
|
|
|
|
+ const collectedProjects = []
|
|
|
|
|
+ let nullCount = 0
|
|
|
|
|
+ // iterate over the project ids in batches of doing a redis MGET to retrieve the first op timestamps
|
|
|
|
|
+ for (const batch of getBatches(projectIdsToProcess, batchSize)) {
|
|
|
|
|
+ const timestamps = await RedisManager.promises.getFirstOpTimestamps(batch)
|
|
|
|
|
+ const newProjects = batch
|
|
|
|
|
+ .map((projectId, idx) => {
|
|
|
|
|
+ return { projectId, timestamp: timestamps[idx] }
|
|
|
|
|
+ })
|
|
|
|
|
+ .filter(({ timestamp }) => {
|
|
|
|
|
+ if (!timestamp) {
|
|
|
|
|
+ nullCount++
|
|
|
|
|
+ }
|
|
|
|
|
+ return timestamp ? olderThan(maxAge, timestamp) : true
|
|
|
|
|
+ })
|
|
|
|
|
+ collectedProjects.push(...newProjects)
|
|
|
|
|
+ }
|
|
|
|
|
+ // sort the collected projects by ascending timestamp
|
|
|
|
|
+ collectedProjects.sort((a, b) => a.timestamp - b.timestamp)
|
|
|
|
|
+
|
|
|
|
|
+ console.log('number of projects to flush', collectedProjects.length)
|
|
|
|
|
+ console.log('number with null timestamps', nullCount)
|
|
|
|
|
+
|
|
|
|
|
+ const elapsedTime = Math.floor((Date.now() - SCRIPT_START_TIME) / 1000)
|
|
|
|
|
+ console.log('elapsed time', elapsedTime, 'seconds, buffer time', bufferTime)
|
|
|
|
|
+ const remainingTime = Math.max(interval - elapsedTime - bufferTime, 0)
|
|
|
|
|
+ console.log('remaining time', remainingTime, 'seconds')
|
|
|
|
|
+
|
|
|
|
|
+ const jobsPerSecond = Math.max(
|
|
|
|
|
+ Math.ceil(collectedProjects.length / Math.max(remainingTime, 60)),
|
|
|
|
|
+ 1
|
|
|
|
|
+ )
|
|
|
|
|
+ console.log('interval', interval, 'seconds')
|
|
|
|
|
+ console.log('jobs per second', jobsPerSecond)
|
|
|
|
|
+ console.log('concurrency', concurrency)
|
|
|
|
|
+
|
|
|
|
|
+ const queue = new PQueue({
|
|
|
|
|
+ concurrency,
|
|
|
|
|
+ interval: 1000,
|
|
|
|
|
+ intervalCap: jobsPerSecond,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const taskFns = collectedProjects.map(project => {
|
|
|
|
|
+ return async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await flushProject(project)
|
|
|
|
|
+ return { status: 'fulfilled', value: project }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ return { status: 'rejected', reason: error, project }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const results = await queue.addAll(taskFns)
|
|
|
|
|
+
|
|
|
|
|
+ console.log(
|
|
|
|
|
+ 'finished after',
|
|
|
|
|
+ Math.floor((Date.now() - SCRIPT_START_TIME) / 1000),
|
|
|
|
|
+ 'seconds'
|
|
|
|
|
+ )
|
|
|
|
|
+ // count the number of successful and failed flushes
|
|
|
|
|
+ const success = results.filter(r => r.status === 'fulfilled').length
|
|
|
|
|
+ const failed = results.filter(r => r.status === 'rejected').length
|
|
|
|
|
+ console.log('completed', { success, failed })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+main()
|
|
|
|
|
+ .then(() => {
|
|
|
|
|
+ process.exit(0)
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(err => {
|
|
|
|
|
+ console.error(err)
|
|
|
|
|
+ process.exit(1)
|
|
|
|
|
+ })
|