mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
Merge pull request #25743 from overleaf/bg-deactivate-projects-script
add deactivate projects script GitOrigin-RevId: 5acf4b980d8980457930ee488571362da2a8014c
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
214
services/web/scripts/deactivate_projects.mjs
Executable file
214
services/web/scripts/deactivate_projects.mjs
Executable 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)
|
||||
})
|
||||
Reference in New Issue
Block a user