Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions create-a-container/job-runner.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node
/**
* job-runner.js
* - Checks ScheduledJobs and creates pending Jobs when schedule conditions are met
* - Polls the Jobs table for pending jobs
* - Claims a job (transactionally), sets status to 'running'
* - Spawns the configured command and streams stdout/stderr into JobStatuses
Expand All @@ -9,6 +10,7 @@

const { spawn } = require('child_process');
const path = require('path');
const parser = require('cron-parser');
const db = require('./models');

const POLL_INTERVAL_MS = parseInt(process.env.JOB_RUNNER_POLL_MS || '2000', 10);
Expand All @@ -17,6 +19,59 @@ const WORKDIR = process.env.JOB_RUNNER_CWD || process.cwd();
let shuttingDown = false;
// Map of jobId -> child process for active/running jobs
const activeChildren = new Map();
// Track last scheduled job execution time to avoid duplicate runs
const lastScheduledExecution = new Map();

async function shouldScheduledJobRun(scheduledJob) {
try {
const interval = parser.parseExpression(scheduledJob.schedule);
const now = new Date();
const lastExecution = lastScheduledExecution.get(scheduledJob.id);

// Get the next occurrence from the schedule
const nextExecution = interval.next().toDate();
const currentMinute = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes());
const nextMinute = new Date(nextExecution.getFullYear(), nextExecution.getMonth(), nextExecution.getDate(), nextExecution.getHours(), nextExecution.getMinutes());

// If the next scheduled time is now and we haven't executed in this minute
if (currentMinute.getTime() === nextMinute.getTime()) {
if (!lastExecution || lastExecution.getTime() < currentMinute.getTime()) {
return true;
}
}
return false;
} catch (err) {
console.error(`Error parsing schedule for job ${scheduledJob.id}: ${err.message}`);
return false;
}
}

async function processScheduledJobs() {
try {
const scheduledJobs = await db.ScheduledJob.findAll();

for (const scheduledJob of scheduledJobs) {
if (await shouldScheduledJobRun(scheduledJob)) {
console.log(`JobRunner: Creating job from scheduled job ${scheduledJob.id}: ${scheduledJob.schedule}`);

try {
await db.Job.create({
command: scheduledJob.command,
status: 'pending',
createdBy: `ScheduledJob#${scheduledJob.id}`
});

// Mark that we've executed this scheduled job at this time
lastScheduledExecution.set(scheduledJob.id, new Date());
} catch (err) {
console.error(`Error creating job from scheduled job ${scheduledJob.id}:`, err);
}
}
}
} catch (err) {
console.error('Error processing scheduled jobs:', err);
}
}

async function claimPendingJob() {
const sequelize = db.sequelize;
Expand Down Expand Up @@ -139,6 +194,10 @@ async function shutdownAndCancelJobs(signal) {
async function loop() {
if (shuttingDown) return;
try {
// Check for scheduled jobs that should run
await processScheduledJobs();

// Check for pending jobs
const job = await claimPendingJob();
if (job) {
// Run job but don't block polling loop; we will wait for job to update
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable('ScheduledJobs', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
schedule: {
type: Sequelize.STRING(255),
allowNull: false,
comment: 'Cron-style schedule expression (e.g., "0 2 * * *" for daily at 2 AM)'
},
command: {
type: Sequelize.STRING(2000),
allowNull: false
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable('ScheduledJobs');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('Nodes', 'defaultStorage', {
type: Sequelize.STRING(255),
allowNull: true,
comment: 'Default storage target for container templates and images'
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('Nodes', 'defaultStorage');
}
};
5 changes: 5 additions & 0 deletions create-a-container/models/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ module.exports = (sequelize, DataTypes) => {
tlsVerify: {
type: DataTypes.BOOLEAN,
allowNull: true
},
defaultStorage: {
type: DataTypes.STRING(255),
allowNull: true,
comment: 'Default storage target for container templates and images'
}
}, {
sequelize,
Expand Down
24 changes: 24 additions & 0 deletions create-a-container/models/scheduled-job.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict';
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class ScheduledJob extends Model {
static associate(models) {
// ScheduledJob can be associated with created Jobs if needed
}
}
ScheduledJob.init({
schedule: {
type: DataTypes.STRING(255),
allowNull: false,
comment: 'Cron-style schedule expression (e.g., "0 2 * * *" for daily at 2 AM)'
},
command: {
type: DataTypes.STRING(2000),
allowNull: false
}
}, {
sequelize,
modelName: 'ScheduledJob'
});
return ScheduledJob;
};
1 change: 1 addition & 0 deletions create-a-container/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"argon2": "^0.44.0",
"axios": "^1.12.2",
"connect-flash": "^0.1.1",
"cron-parser": "^4.1.0",
"dotenv": "^17.2.3",
"ejs": "^3.1.10",
"express": "^5.2.1",
Expand Down
21 changes: 21 additions & 0 deletions create-a-container/seeders/20251203000000-seed-oci-build-job.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.bulkInsert('ScheduledJobs', [
{
schedule: '0 2 * * *',
command: 'node -e "require(\'./utils/oci-build-job\').run()"',
createdAt: new Date(),
updatedAt: new Date()
}
], {});
},

async down(queryInterface, Sequelize) {
await queryInterface.bulkDelete('ScheduledJobs', {
command: { [Sequelize.Op.like]: '%oci-build-job%' }
}, {});
}
};
Loading
Loading