From 4bc0f44789e9a68b661b5e173d38d2285a649ebd Mon Sep 17 00:00:00 2001 From: Girish Ramakrishnan Date: Sat, 7 Mar 2026 22:30:43 +0530 Subject: [PATCH] services: lazy start services / on demand services services are now stopped when no app is using them. on start up, services are always created and run. they are later stopped when unused. it's this way to facilitate the upgrade code path. the database meta files have to be upgraded even if no app is using them. the other hook to stop unused services is at the end of an app task. maybe mail container is a candidate for the future where all sending is no-op. But give this is rare, it's not implemented. --- CHANGES | 3 +++ src/apps.js | 5 +++- src/apptask.js | 7 ++---- src/apptaskmanager.js | 13 +++++++--- src/platform.js | 3 +++ src/services.js | 55 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 77 insertions(+), 9 deletions(-) diff --git a/CHANGES b/CHANGES index 7a989fb3d..619f5c975 100644 --- a/CHANGES +++ b/CHANGES @@ -3169,3 +3169,6 @@ * backupintegrity: add percent progress * apps: fix acl display +[9.2.0] +* services: lazy start services / on demand services + diff --git a/src/apps.js b/src/apps.js index e410a1af8..f4865c490 100644 --- a/src/apps.js +++ b/src/apps.js @@ -1055,7 +1055,6 @@ async function onTaskFinished(error, appId, installationState, taskId, auditSour const app = await get(appId); const task = await tasks.get(taskId); - if (!app || !task) return; switch (installationState) { case ISTATE_PENDING_DATA_DIR_MIGRATION: @@ -1070,6 +1069,10 @@ async function onTaskFinished(error, appId, installationState, taskId, auditSour break; } } + + // this can race with an new install task but very unlikely + debug(`onTaskFinished: checking to stop unused services. hasPending: ${appTaskManager.hasPendingTasks()}`) + if (!appTaskManager.hasPendingTasks()) safe(services.stopUnusedServices(), { debug }); } async function getCount() { diff --git a/src/apptask.js b/src/apptask.js index 9db41f171..a001858ae 100644 --- a/src/apptask.js +++ b/src/apptask.js @@ -406,13 +406,10 @@ async function installCommand(app, args, progressCallback) { await deleteContainers(app, { managedOnly: true }); // when restoring, app does not require these addons anymore. remove carefully to preserve the db passwords - let addonsToRemove; if (oldManifest) { - addonsToRemove = _.omit(oldManifest.addons, Object.keys(app.manifest.addons)); - } else { - addonsToRemove = app.manifest.addons; + const addonsToRemove = _.omit(oldManifest.addons, Object.keys(app.manifest.addons)); + await services.teardownAddons(app, addonsToRemove); } - await services.teardownAddons(app, addonsToRemove); if (!restoreConfig || restoreConfig.remotePath || restoreConfig.backupId) { // install/import/restore but not in-place import should delete data dir await deleteAppDir(app, { removeDirectory: false }); // do not remove any symlinked appdata dir diff --git a/src/apptaskmanager.js b/src/apptaskmanager.js index f20d10162..b0df2158d 100644 --- a/src/apptaskmanager.js +++ b/src/apptaskmanager.js @@ -47,11 +47,13 @@ async function drain() { scheduler.suspendAppJobs(appId); // background + let taskError = null, taskResult = null; tasks.startTask(taskId, Object.assign(options, { logFile })) - .then(async (result) => await safe(onFinished(null, result), { debug })) - .catch(async (error) => await safe(onFinished(error), { debug })) + .then((result) => { taskResult = result; }) + .catch((error) => { taskError = error; }) .finally(async () => { delete gActiveTasks[appId]; + await safe(onFinished(taskError, taskResult), { debug }); // hasPendingTasks() can now return false await locks.release(`${locks.TYPE_APP_TASK_PREFIX}${appId}`); await locks.releaseByTaskId(taskId); scheduler.resumeAppJobs(appId); @@ -89,7 +91,12 @@ function scheduleTask(appId, taskId, options, onFinished) { if (gStarted && !gDrainTimerId) gDrainTimerId = setTimeout(drain, DRAIN_TIMER_SECS); } +function hasPendingTasks() { + return Object.keys(gActiveTasks).length > 0 || gPendingTasks.length > 0; +} + export default { start, - scheduleTask + scheduleTask, + hasPendingTasks }; diff --git a/src/platform.js b/src/platform.js index a2deddd56..9a05960c3 100644 --- a/src/platform.js +++ b/src/platform.js @@ -101,6 +101,9 @@ async function onInfraReady(infraChanged) { if (infraChanged) await safe(pruneVolumes(), { debug }); // ignore error await apps.schedulePendingTasks(AuditSource.PLATFORM); await appTaskManager.start(); + + // only prune services on infra change (which starts services for upgrade) + if (infraChanged) safe(services.stopUnusedServices(), { debug }); } async function startInfra(restoreOptions) { diff --git a/src/services.js b/src/services.js index 7aa3c4b74..274b3da40 100644 --- a/src/services.js +++ b/src/services.js @@ -41,6 +41,7 @@ const SERVICE_STATUS_DISABLED = 'disabled'; const NOOP = async function (/*app, options*/) {}; +const LAZY_SERVICES = ['mysql', 'postgresql', 'mongodb', 'turn']; const RMADDONDIR_CMD = path.join(import.meta.dirname, 'scripts/rmaddondir.sh'); const RESTART_SERVICE_CMD = path.join(import.meta.dirname, 'scripts/restartservice.sh'); const CLEARVOLUME_CMD = path.join(import.meta.dirname, 'scripts/clearvolume.sh'); @@ -199,6 +200,43 @@ async function waitForContainer(containerName, tokenEnvName) { }); } +async function ensureServiceRunning(serviceName) { + assert.strictEqual(typeof serviceName, 'string'); + + const tokenEnvNames = { + mysql: 'CLOUDRON_MYSQL_TOKEN', + postgresql: 'CLOUDRON_POSTGRESQL_TOKEN', + mongodb: 'CLOUDRON_MONGODB_TOKEN', + }; + + const [error, container] = await safe(docker.inspect(serviceName)); + if (error) throw new BoxError(BoxError.ADDONS_ERROR, `${serviceName} container not found`); + if (container.State?.Running) return; + + debug(`ensureServiceRunning: starting ${serviceName}`); + await docker.startContainer(serviceName); + + if (tokenEnvNames[serviceName]) await waitForContainer(serviceName, tokenEnvNames[serviceName]); +} + +async function stopUnusedServices() { + const allApps = await apps.list(); + const usedAddons = new Set(); + for (const app of allApps) { + for (const addon of Object.keys(app.manifest.addons || {})) { + usedAddons.add(addon); + } + } + + debug(`stopUnusedServices: used addons - ${[...usedAddons]}`); + + for (const name of LAZY_SERVICES) { + if (usedAddons.has(name)) continue; + debug(`stopUnusedServices: stopping ${name} (no apps use it)`); + await safe(docker.stopContainer(name), { debug }); + } +} + async function exportDatabase(addon) { assert.strictEqual(typeof addon, 'string'); @@ -445,6 +483,8 @@ async function setupTurn(app, options) { const disabled = app.manifest.addons.turn.optional && !app.enableTurn; if (disabled) return await addonConfigs.unset(app.id, 'turn'); + await ensureServiceRunning('turn'); + const turnSecret = await blobs.getString(blobs.ADDON_TURN_SECRET); if (!turnSecret) throw new BoxError(BoxError.ADDONS_ERROR, 'Turn secret is missing'); @@ -688,6 +728,8 @@ async function setupMySql(app, options) { debug('Setting up mysql'); + await ensureServiceRunning('mysql'); + const existingPassword = await addonConfigs.getByName(app.id, 'mysql', '%MYSQL_PASSWORD'); const tmp = mysqlDatabaseName(app.id); @@ -855,6 +897,8 @@ async function setupPostgreSql(app, options) { debug('Setting up postgresql'); + await ensureServiceRunning('postgresql'); + const { database, username } = postgreSqlNames(app.id); const existingPassword = await addonConfigs.getByName(app.id, 'postgresql', '%POSTGRESQL_PASSWORD'); @@ -1010,6 +1054,8 @@ async function setupMongoDb(app, options) { debug('Setting up mongodb'); + await ensureServiceRunning('mongodb'); + if (!await hasAVX()) throw new BoxError(BoxError.ADDONS_ERROR, 'Error setting up MongoDB. CPU has no AVX support'); const existingPassword = await addonConfigs.getByName(app.id, 'mongodb', '%MONGODB_PASSWORD'); @@ -1686,6 +1732,14 @@ async function applyMemoryLimit(id) { throw new BoxError(BoxError.NOT_FOUND, 'No such service'); } + if (LAZY_SERVICES.includes(name)) { + const [error, container] = await safe(docker.inspect(containerName)); + if (error || !container.State?.Running) { + debug(`applyMemoryLimit: skipping ${containerName} (not running)`); + return; + } + } + debug(`applyMemoryLimit: ${containerName} ${JSON.stringify(serviceConfig)}`); await docker.update(containerName, memoryLimit); @@ -2270,6 +2324,7 @@ export default { stopAppServices, startServices, + stopUnusedServices, applyServiceLimits, moveDataDir, // localstorage specific command