dynamiccronjobs

  • community
  • community-management
  • by Limon
  • Takaro main
  • all
Version
View export JSON

Dynamic CronJob Manager: Automated Game Server Command Scheduler


🎥 SETUP TUTORIAL
► Watch the Complete Guide
Learn how to set up and configure dynamic cronjobs

Create and manage dynamic cronjobs for executing game server commands based on flexible schedules. Define commands and their cron expressions to automate tasks.

  • Dynamic Updates: The module's cronJobGenerator cronjob automatically checks for and adapts to changes in the user configuration. This ensures that any modifications to commands or schedules are applied dynamically.
  • Cron Syntax Validation: The module validates the syntax of cron expressions. If an invalid expression is provided, that specific cronjob will be skipped, preventing errors and ensuring only valid schedules are applied. For assistance with cron syntax, you can use a tool like crontab.guru.
  • Command Sequencing: You can define multiple commands within a single cronjob entry, separated by semicolons. These commands will be executed sequentially according to the defined schedule.
  • Game Server Specificity: It is crucial to install the latest version of this module and create a separate copy for each game server. This ensures that cronjobs are executed on the intended server and avoids unintended cross-server execution.
  • Automated Management: The module handles the creation, updating, and cleanup of cronjobs. It removes any cronjobs that are no longer present in the configuration, maintaining a clean and accurate schedule.
  • Internal Synchronization: To ensure proper synchronization of cron schedules, the module may perform internal operations to update its configuration. This process is automated and does not require manual intervention from the user.

Configuration 1

Settings you fill in when installing the module on a server.

SettingTypeDefaultDescription
cronjobs Cronjobs array List of cronjobs and their schedules
Raw config schema
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "cronjobs": {
      "type": "array",
      "title": "Cronjobs",
      "description": "List of cronjobs and their schedules",
      "items": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "Optional name for the cronjob",
            "minLength": 1
          },
          "command": {
            "type": "string",
            "description": "You can enter multiple commands separated by semicolons (;) to create a sequence of scheduled tasks for the same cron expression. ",
            "minLength": 1
          },
          "temporalValue": {
            "type": "string",
            "description": "Cron expression for execution schedule",
            "minLength": 1
          }
        },
        "required": [
          "command",
          "temporalValue"
        ]
      }
    }
  },
  "additionalProperties": false
}
Raw UI schema
{}

Cron jobs 1

Work the module runs on a schedule.

  • cronJobGenerator

    Check changes in the user config and adapts (optimized: runs every 15 minutes with smart change detection)

    Cron job source
    import { takaro, data } from '@takaro/helpers';
    
    async function main() {
      const { gameServerId, module: mod } = data;
      const cronjobs = mod.userConfig.cronjobs || [];
    
      console.log('🔄 Dynamic Cronjob Generator starting...');
    
      // State management using variables
      const configHashKey = 'config_hash';
      const lastProcessedKey = 'last_processed';
      const backupConfigKey = 'backup_config';
    
      try {
        // Create configuration hash for change detection
        const currentConfigHash = JSON.stringify(cronjobs).split('').reduce((a, b) => {
          a = ((a << 5) - a) + b.charCodeAt(0);
          return a & a;
        }, 0).toString();
    
        // Get stored state
        let storedHash = null;
        let lastProcessed = null;
        try {
          const hashVar = await takaro.variable.variableControllerSearch({
            filters: {
              key: [configHashKey],
              moduleId: [mod.moduleId],
              gameServerId: [gameServerId]
            }
          });
          if (hashVar.data.data.length > 0) {
            storedHash = hashVar.data.data[0].value;
          }
    
          const processedVar = await takaro.variable.variableControllerSearch({
            filters: {
              key: [lastProcessedKey],
              moduleId: [mod.moduleId],
              gameServerId: [gameServerId]
            }
          });
          if (processedVar.data.data.length > 0) {
            lastProcessed = new Date(processedVar.data.data[0].value);
          }
        } catch (error) {
          console.log('📝 No previous state found, treating as first run');
        }
    
        // Check if configuration changed
        if (storedHash === currentConfigHash && lastProcessed && (Date.now() - lastProcessed.getTime()) < 300000) { // 5 minutes
          console.log('✅ No configuration changes detected, skipping update');
          return;
        }
    
        // Backup current configuration
        try {
          await takaro.variable.variableControllerCreate({
            key: backupConfigKey,
            value: JSON.stringify({
              userConfig: mod.userConfig,
              timestamp: new Date().toISOString(),
              hash: currentConfigHash
            }),
            gameServerId,
            moduleId: mod.moduleId
          });
          console.log('💾 Configuration backup created');
        } catch (backupError) {
          // Update existing backup
          const existingBackup = await takaro.variable.variableControllerSearch({
            filters: {
              key: [backupConfigKey],
              moduleId: [mod.moduleId],
              gameServerId: [gameServerId]
            }
          });
          if (existingBackup.data.data.length > 0) {
            await takaro.variable.variableControllerUpdate(existingBackup.data.data[0].id, {
              value: JSON.stringify({
                userConfig: mod.userConfig,
                timestamp: new Date().toISOString(),
                hash: currentConfigHash
              })
            });
            console.log('💾 Configuration backup updated');
          }
        }
    
        // Validate cronjobs first
        const validatedCronjobs = [];
        const invalidJobs = [];
    
        function isValidCronSyntax(cronExpression) {
          try {
            cronExpression = cronExpression.trim();
    
            // Check for special expressions
            if (/^@(yearly|annually|monthly|weekly|daily|hourly|reboot)$/.test(cronExpression)) {
              return true;
            }
    
            const parts = cronExpression.split(/\s+/);
            if (parts.length !== 5) {
              return false;
            }
    
            // Valid characters per field
            const validPatterns = [
              /^[0-9,\-*\/]+$/, // Minutes
              /^[0-9,\-*\/]+$/, // Hours  
              /^[0-9,\-*\/?]+$/, // Day of month
              /^[0-9,\-*\/]+$/, // Month
              /^[0-9,\-*\/?a-zA-Z]+$/ // Day of week
            ];
    
            for (let i = 0; i < 5; i++) {
              if (!validPatterns[i].test(parts[i])) {
                return false;
              }
            }
    
            // Range validation
            const minutes = parts[0].split(/[,\-\/]/).filter(m => m !== '*' && /^\d+$/.test(m));
            if (minutes.some(m => parseInt(m) > 59)) return false;
    
            const hours = parts[1].split(/[,\-\/]/).filter(h => h !== '*' && /^\d+$/.test(h));
            if (hours.some(h => parseInt(h) > 23)) return false;
    
            const dom = parts[2].split(/[,\-\/]/).filter(d => d !== '*' && d !== '?' && /^\d+$/.test(d));
            if (dom.some(d => parseInt(d) < 1 || parseInt(d) > 31)) return false;
    
            const months = parts[3].split(/[,\-\/]/).filter(m => m !== '*' && /^\d+$/.test(m));
            if (months.some(m => parseInt(m) < 1 || parseInt(m) > 12)) return false;
    
            return true;
          } catch (err) {
            return false;
          }
        }
    
        // Validate all cronjobs
        for (let i = 0; i < cronjobs.length; i++) {
          const job = cronjobs[i];
          if (!isValidCronSyntax(job.temporalValue)) {
            console.log(`❌ Invalid cron syntax in job ${job.name || i + 1}: "${job.temporalValue}"`);
            invalidJobs.push(job);
            continue;
          }
          validatedCronjobs.push(job);
        }
    
        if (invalidJobs.length > 0) {
          console.log(`⚠️  Skipped ${invalidJobs.length} invalid cronjob(s)`);
        }
    
        // Get existing cronjobs for this module
        const existingCronjobs = (await takaro.cronjob.cronJobControllerSearch({
          filters: {
            versionId: [mod.versionId]
          }
        })).data.data;
    
        const processedJobs = new Set();
        let jobCounter = 1;
        let successCount = 0;
        let errorCount = 0;
    
        // Process validated cronjobs with individual CRUD operations
        for (let i = 0; i < validatedCronjobs.length; i++) {
          const job = validatedCronjobs[i];
          const commands = job.command.split(';').map(cmd => cmd.trim()).filter(cmd => cmd);
    
          for (let cmdIndex = 0; cmdIndex < commands.length; cmdIndex++) {
            const command = commands[cmdIndex];
            const seqSuffix = commands.length > 1 ? `-seq${cmdIndex + 1}` : '';
            const jobNameSuffix = job.name ? `-${job.name}${seqSuffix}` : seqSuffix;
            const jobName = `cr-${jobCounter}${jobNameSuffix}`;
            jobCounter++;
    
            processedJobs.add(jobName);
    
            const jobFunction = `
    import { takaro, data } from '@takaro/helpers';
    async function main() {
      const { gameServerId } = data;
      await takaro.gameserver.gameServerControllerExecuteCommand(gameServerId, {
        command: \`${command}\`,
      });
    }
    await main();
            `.trim();
    
            try {
              const existingJob = existingCronjobs.find(j => j.name === jobName);
    
              if (existingJob) {
                // Only update if temporal value changed
                if (existingJob.temporalValue !== job.temporalValue) {
                  console.log(`📝 Updating cronjob ${jobName}: ${existingJob.temporalValue} → ${job.temporalValue}`);
                  await takaro.cronjob.cronJobControllerUpdate(existingJob.id, {
                    name: jobName,
                    temporalValue: job.temporalValue,
                    function: jobFunction
                  });
                  successCount++;
                }
              } else {
                console.log(`➕ Creating new cronjob: ${jobName}`);
                try {
                  await takaro.cronjob.cronJobControllerCreate({
                    name: jobName,
                    temporalValue: job.temporalValue,
                    versionId: mod.versionId,
                    function: jobFunction
                  });
                  successCount++;
                } catch (createError) {
                  if (createError.response?.status === 409) {
                    // Handle conflict by searching and updating
                    const conflictJobSearch = await takaro.cronjob.cronJobControllerSearch({
                      filters: { name: [jobName] }
                    });
    
                    if (conflictJobSearch.data.data.length > 0) {
                      const conflictJob = conflictJobSearch.data.data[0];
                      await takaro.cronjob.cronJobControllerUpdate(conflictJob.id, {
                        name: jobName,
                        temporalValue: job.temporalValue,
                        function: jobFunction
                      });
                      successCount++;
                      console.log(`📝 Resolved conflict and updated: ${jobName}`);
                    } else {
                      throw createError;
                    }
                  } else {
                    throw createError;
                  }
                }
              }
            } catch (error) {
              console.log(`❌ Error with cronjob "${jobName}": ${error.message || "Unknown error"}`);
              errorCount++;
            }
          }
        }
    
        // Clean up orphaned cronjobs
        let cleanupCount = 0;
        for (const existingJob of existingCronjobs) {
          if (/^cr-\d+(-.*)?$/.test(existingJob.name) && !processedJobs.has(existingJob.name)) {
            try {
              await takaro.cronjob.cronJobControllerRemove(existingJob.id);
              cleanupCount++;
              console.log(`🗑️  Removed orphaned cronjob: ${existingJob.name}`);
            } catch (error) {
              console.log(`❌ Error removing cronjob "${existingJob.name}": ${error.message}`);
              errorCount++;
            }
          }
        }
    
        // Update state variables
        try {
          // Update config hash
          const hashVarSearch = await takaro.variable.variableControllerSearch({
            filters: {
              key: [configHashKey],
              moduleId: [mod.moduleId],
              gameServerId: [gameServerId]
            }
          });
    
          if (hashVarSearch.data.data.length > 0) {
            await takaro.variable.variableControllerUpdate(hashVarSearch.data.data[0].id, {
              value: currentConfigHash
            });
          } else {
            await takaro.variable.variableControllerCreate({
              key: configHashKey,
              value: currentConfigHash,
              gameServerId,
              moduleId: mod.moduleId
            });
          }
    
          // Update last processed timestamp
          const processedVarSearch = await takaro.variable.variableControllerSearch({
            filters: {
              key: [lastProcessedKey],
              moduleId: [mod.moduleId],
              gameServerId: [gameServerId]
            }
          });
    
          if (processedVarSearch.data.data.length > 0) {
            await takaro.variable.variableControllerUpdate(processedVarSearch.data.data[0].id, {
              value: new Date().toISOString()
            });
          } else {
            await takaro.variable.variableControllerCreate({
              key: lastProcessedKey,
              value: new Date().toISOString(),
              gameServerId,
              moduleId: mod.moduleId
            });
          }
    
          console.log('📊 State variables updated successfully');
        } catch (stateError) {
          console.log(`⚠️  Warning: Could not update state variables: ${stateError.message}`);
        }
    
        console.log(`✅ Dynamic Cronjob Generator completed:`);
        console.log(`   📈 ${successCount} operations successful`);
        console.log(`   🗑️  ${cleanupCount} orphaned jobs cleaned`);
        if (errorCount > 0) {
          console.log(`   ❌ ${errorCount} errors encountered`);
        }
    
      } catch (error) {
        console.log(`💥 Critical error in Dynamic Cronjob Generator: ${error.message}`);
        
        // Attempt to restore from backup if available
        try {
          const backupSearch = await takaro.variable.variableControllerSearch({
            filters: {
              key: [backupConfigKey],
              moduleId: [mod.moduleId],
              gameServerId: [gameServerId]
            }
          });
    
          if (backupSearch.data.data.length > 0) {
            const backup = JSON.parse(backupSearch.data.data[0].value);
            console.log(`🔄 Backup available from ${backup.timestamp}`);
            console.log('   Manual restoration may be required if issues persist');
          }
        } catch (backupError) {
          console.log('⚠️  No backup available for restoration');
        }
    
        throw error; // Re-throw to ensure error is logged in events
      }
    }
    
    await main();