Merge pull request #25743 from overleaf/bg-deactivate-projects-script

add deactivate projects script

GitOrigin-RevId: 5acf4b980d8980457930ee488571362da2a8014c
This commit is contained in:
Brian Gough
2025-05-20 09:47:19 +01:00
committed by Copybot
parent 1354465562
commit efd55ffe97
3 changed files with 261 additions and 35 deletions

View File

@@ -11,6 +11,27 @@ const { callbackifyAll } = require('@overleaf/promise-utils')
const Metrics = require('@overleaf/metrics')
const MILISECONDS_IN_DAY = 86400000
function findInactiveProjects(limit, daysOld) {
const oldProjectDate = new Date() - MILISECONDS_IN_DAY * daysOld
try {
// use $not $gt to catch non-opened projects where lastOpened is null
// return a cursor instead of executing the query
return Project.find({
lastOpened: { $not: { $gt: oldProjectDate } },
})
.where('active')
.equals(true)
.select(['_id', 'lastOpened'])
.limit(limit)
.read(READ_PREFERENCE_SECONDARY)
.cursor()
} catch (err) {
logger.err({ err }, 'could not get projects for deactivating')
throw err // Re-throw the error to be handled by the caller
}
}
const InactiveProjectManager = {
async reactivateProjectIfRequired(projectId) {
let project
@@ -53,30 +74,13 @@ const InactiveProjectManager = {
if (daysOld == null) {
daysOld = 360
}
const oldProjectDate = new Date() - MILISECONDS_IN_DAY * daysOld
let projects
try {
// use $not $gt to catch non-opened projects where lastOpened is null
projects = await Project.find({
lastOpened: { $not: { $gt: oldProjectDate } },
})
.where('active')
.equals(true)
.select('_id')
.limit(limit)
.read(READ_PREFERENCE_SECONDARY)
.exec()
} catch (err) {
logger.err({ err }, 'could not get projects for deactivating')
}
logger.debug('deactivating projects')
logger.debug(
{ numberOfProjects: projects && projects.length },
'deactivating projects'
)
const processedProjects = []
for (const project of projects) {
for await (const project of findInactiveProjects(limit, daysOld)) {
processedProjects.push(project)
try {
await InactiveProjectManager.deactivateProject(project._id)
} catch (err) {
@@ -87,7 +91,12 @@ const InactiveProjectManager = {
}
}
return projects
logger.debug(
{ numberOfProjects: processedProjects.length },
'finished deactivating projects'
)
return processedProjects
},
async deactivateProject(projectId) {
@@ -126,4 +135,5 @@ const InactiveProjectManager = {
module.exports = {
...callbackifyAll(InactiveProjectManager),
promises: InactiveProjectManager,
findInactiveProjects,
}

View File

@@ -65,20 +65,22 @@ async function gracefulShutdown(server, signal) {
true
)
await sleep(Settings.gracefulShutdownDelayInMs)
try {
await new Promise((resolve, reject) => {
logger.warn({}, 'graceful shutdown: closing http server')
server.close(err => {
if (err) {
reject(OError.tag(err, 'http.Server.close failed'))
} else {
resolve()
}
if (server) {
await sleep(Settings.gracefulShutdownDelayInMs)
try {
await new Promise((resolve, reject) => {
logger.warn({}, 'graceful shutdown: closing http server')
server.close(err => {
if (err) {
reject(OError.tag(err, 'http.Server.close failed'))
} else {
resolve()
}
})
})
})
} catch (err) {
throw OError.tag(err, 'stop traffic')
} catch (err) {
throw OError.tag(err, 'stop traffic')
}
}
await runHandlers(

View File

@@ -0,0 +1,214 @@
#!/usr/bin/env node
import minimist from 'minimist'
import PQueue from 'p-queue'
import InactiveProjectManager from '../app/src/Features/InactiveData/InactiveProjectManager.js'
import { gracefulShutdown } from '../app/src/infrastructure/GracefulShutdown.js'
import logger from '@overleaf/logger'
// Global variables for tracking job and error counts
let jobCount = 0
let succeededCount = 0
let skippedCount = 0
let failedCount = 0
let currentAgeInDays = null
let currentLastOpened = null
let DRY_RUN = false
let gracefulShutdownInitiated = false
const SCRIPT_START_TIME = Date.now()
const MAX_RUNTIME_DEFAULT = null
let MAX_RUNTIME = MAX_RUNTIME_DEFAULT // in milliseconds
// Configure signal handling
process.on('SIGINT', handleSignal)
process.on('SIGTERM', handleSignal)
function handleSignal() {
if (gracefulShutdownInitiated) return
gracefulShutdownInitiated = true
logger.warn(
{ gracefulShutdownInitiated },
'graceful shutdown initiated, draining queue'
)
}
// Check if max runtime has been exceeded
function hasMaxRuntimeExceeded() {
if (MAX_RUNTIME === null) return false
const elapsedTime = Date.now() - SCRIPT_START_TIME
const hasExceeded = elapsedTime >= MAX_RUNTIME
if (hasExceeded && !gracefulShutdownInitiated) {
gracefulShutdownInitiated = true
logger.warn(
{ elapsedTimeMs: elapsedTime, maxRuntimeMs: MAX_RUNTIME },
'maximum runtime exceeded, initiating graceful shutdown'
)
}
return hasExceeded
}
// Calculates the age in days since the provided lastOpened date.
function getAgeFromLastOpened(lastOpened) {
const lastOpenedDate = new Date(lastOpened)
const now = new Date()
return Number(((now - lastOpenedDate) / (1000 * 60 * 60 * 24)).toFixed(2))
}
// Deactivates a single project and handles errors
async function deactivateSingleProject(project) {
const { _id: projectId, lastOpened } = project
jobCount++
if (lastOpened) {
currentLastOpened = lastOpened
currentAgeInDays = getAgeFromLastOpened(lastOpened)
}
// Periodic progress logging
if (jobCount % 1000 === 0) {
logger.info(
{ jobCount, failedCount, currentAgeInDays },
'project deactivation in progress'
)
}
// Debug level detail logging
logger.debug(
{ projectId, jobCount, failedCount, dryRun: DRY_RUN },
'attempting to deactivate project'
)
// Dry run handling
if (DRY_RUN) {
logger.info({ projectId }, '[DRY RUN] would deactivate project')
succeededCount++
}
// Actual deactivation with error handling
try {
await InactiveProjectManager.promises.deactivateProject(projectId)
logger.debug({ projectId }, 'successfully deactivated project')
succeededCount++
} catch (error) {
failedCount++
logger.error({ projectId, err: error }, 'failed to deactivate project')
}
}
// Centralized project processing function
async function processProjects(projectCursor, concurrency) {
const queue = new PQueue({ concurrency })
for await (const project of projectCursor) {
if (gracefulShutdownInitiated || hasMaxRuntimeExceeded()) {
skippedCount++
break
}
await queue.onEmpty()
logger.debug(
{ queueSize: queue.size, queuePending: queue.pending },
'queue size before adding new job'
)
queue.add(async () => {
await deactivateSingleProject(project)
})
}
await queue.onIdle()
}
const usage = `
Usage: scripts/deactivate_projects.mjs [options]
Options:
--limit <number> Max number of projects to process (default: 10)
--daysOld <number> Min age in days for a project to be considered inactive (default: 7)
--concurrency <number> Number of deactivations to run in parallel (default: 1)
--max-time <number> Maximum runtime in seconds before graceful shutdown (default: no limit)
--dry-run, -n Simulate deactivation without making changes (default: false)
--help Display this usage message
`
async function main() {
const argv = minimist(process.argv.slice(2), {
string: ['limit', 'daysOld', 'concurrency', 'maxTime'],
boolean: ['dryRun', 'help'],
alias: {
dryRun: ['dry-run', 'n'],
maxTime: 'max-time',
help: 'h',
},
default: {
limit: '10',
daysOld: '7',
concurrency: '1',
maxTime: '',
dryRun: false,
},
})
if (argv.help || process.argv.length <= 2) {
console.log(usage)
process.exit(0)
}
const limit = parseInt(argv.limit, 10)
const daysOld = parseInt(argv.daysOld, 10)
const concurrency = parseInt(argv.concurrency, 10)
const maxRuntimeInSeconds = parseInt(argv.maxTime, 10)
DRY_RUN = argv.dryRun
MAX_RUNTIME = maxRuntimeInSeconds * 1000 // Convert seconds to milliseconds
if (DRY_RUN) {
logger.info(
{},
'DRY RUN MODE ENABLED: No actual deactivations will be performed'
)
}
logger.info(
{
limit,
daysOld,
concurrency,
dryRun: DRY_RUN,
maxRuntimeSeconds: maxRuntimeInSeconds || 'unlimited',
},
'finding inactive projects'
)
try {
// Find projects to deactivate
const projectCursor = await InactiveProjectManager.findInactiveProjects(
limit,
daysOld
)
// Process the projects
await processProjects(projectCursor, concurrency)
} catch (error) {
logger.error({ err: error }, 'critical error during script execution')
process.exitCode = 1
} finally {
logger.info(
{
jobCount,
succeededCount,
failedCount,
skippedCount,
currentAgeInDays,
currentLastOpened,
elapsedTimeInSeconds: Math.floor(
(Date.now() - SCRIPT_START_TIME) / 1000
),
maxRuntimeInSeconds: maxRuntimeInSeconds || 'unlimited',
},
'project deactivation process completed'
)
}
}
main()
.then(async () => {
await gracefulShutdown()
})
.catch(err => {
logger.fatal({ err }, 'unhandled error in main execution')
process.exit(1)
})