Fix DB migration from 8.19 to 8.21 stuck forever.

Thanks to MaccabeeY and xet7 !

Fixes #6078
This commit is contained in:
Lauri Ojansivu 2026-01-21 00:56:42 +02:00
parent e177bf54e2
commit a31a615da6
9 changed files with 869 additions and 71 deletions

View file

@ -862,3 +862,43 @@
max-width: 100%;
}
}
/* Progress bar styles for #cron-setting section */
#cron-setting .progress-section {
margin-top: 15px;
}
#cron-setting .step-counter {
margin-bottom: 8px;
font-weight: 600;
color: #333;
font-size: 14px;
}
#cron-setting .progress {
height: 30px;
background-color: #e9ecef;
border-radius: 4px;
overflow: visible;
margin-bottom: 5px;
max-width: calc(100% - 40px);
}
#cron-setting .progress-bar {
height: 30px;
line-height: 30px;
background-color: #28a745;
color: white;
font-weight: 600;
font-size: 14px;
text-align: center;
transition: width 0.3s ease;
border-radius: 4px;
}
#cron-setting .progress-text {
font-size: 13px;
color: #666;
margin-top: 5px;
max-width: calc(100% - 40px);
}

View file

@ -3,21 +3,52 @@ template(name="cronSettings")
li
h3 {{_ 'cron-migrations'}}
.form-group
label {{_ 'migration-status'}}
.status-indicator
span.status-label {{_ 'status'}}:
span.status-value {{migrationStatus}}
.progress-section
.progress
.progress-bar(role="progressbar" style="width: {{migrationProgress}}%" aria-valuenow="{{migrationProgress}}" aria-valuemin="0" aria-valuemax="100")
| {{migrationProgress}}%
.progress-text
| {{migrationProgress}}% {{_ 'complete'}}
label {{_ 'select-migration'}}
select.js-migration-select.wekan-form-control
option(value="0") 0 - {{_ 'all-migrations'}}
each migrationStepsWithIndex
option(value="{{index}}") {{index}} - {{name}}
.form-group
button.js-start-all-migrations.btn.btn-primary {{_ 'start-all-migrations'}}
button.js-pause-all-migrations.btn.btn-warning {{_ 'pause-all-migrations'}}
button.js-stop-all-migrations.btn.btn-danger {{_ 'stop-all-migrations'}}
label {{_ 'migration-status'}}
.status-indicator
span.status-value {{migrationStatus}}
if isMigrating
.progress-section
.step-counter
| Step {{migrationCurrentStepNum}}/{{migrationTotalSteps}}
.progress
.progress-bar(role="progressbar" style="width: {{migrationProgress}}%" aria-valuenow="{{migrationProgress}}" aria-valuemin="0" aria-valuemax="100")
| {{migrationProgress}}%
.progress-text
| {{migrationProgress}}% {{_ 'complete'}}
.form-group
button.js-start-migration.btn.btn-primary(disabled="{{#if isMigrating}}disabled{{/if}}") {{_ 'start'}}
button.js-pause-migration.btn.btn-warning(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'pause'}}
button.js-stop-migration.btn.btn-danger(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'stop'}}
.form-group.migration-errors-section
h4 {{_ 'cron-migration-errors'}}
if hasErrors
.error-actions
button.js-clear-all-errors.btn.btn-sm.btn-warning {{_ 'cron-clear-errors'}}
.errors-list
each migrationErrors
.error-item(class="error-{{severity}}")
.error-header
span.error-severity(class="severity-{{severity}}") {{severity}}
span.error-time {{formatDateTime createdAt}}
if stepId
span.error-step {{stepId}}
.error-message {{errorMessage}}
if context
.error-context
each contextValue context
span.context-item {{this}}
else
.no-errors
| {{_ 'cron-no-errors'}}
li
h3 {{_ 'board-operations'}}

View file

@ -170,25 +170,22 @@ template(name="setting")
label {{_ 'migration-status'}}
.status-indicator
span.status-label {{_ 'status'}}:
span.status-value {{#if isMigrating}}{{migrationStatus}}{{else}}{{_ 'idle'}}{{/if}}
.current-step(class="{{#unless migrationCurrentStep}}hide{{/unless}}")
span.step-label {{_ 'current-step'}}:
span.step-value {{migrationCurrentStep}}
.progress-section
.progress
.progress-bar(role="progressbar" style="width: {{migrationProgress}}%" aria-valuenow="{{migrationProgress}}" aria-valuemin="0" aria-valuemax="100")
| {{migrationProgress}}%
.progress-text
| {{migrationProgress}}% {{_ 'complete'}}
span.status-value
if isMigrating
i.fa.fa-spinner.fa-spin(style="margin-right: 8px;")
| {{#if isMigrating}}{{migrationStatus}}{{else}}{{_ 'idle'}}{{/if}}
if isMigrating
.progress-section
.progress
.progress-bar(role="progressbar" style="width: {{migrationProgress}}%" aria-valuenow="{{migrationProgress}}" aria-valuemin="0" aria-valuemax="100")
| {{migrationProgress}}%
.progress-text
| {{migrationProgress}}% {{_ 'complete'}}
.form-group
button.js-start-all-migrations.btn.btn-primary {{#if isMigrating}}disabled{{/if}} {{_ 'start-all-migrations'}}
button.js-pause-all-migrations.btn.btn-warning {{#unless isMigrating}}disabled{{/unless}} {{_ 'pause-all-migrations'}}
button.js-stop-all-migrations.btn.btn-danger {{#unless isMigrating}}disabled{{/unless}} {{_ 'stop-all-migrations'}}
li
h3 {{_ 'migration-steps'}}
p Migration steps section temporarily removed
button.js-start-all-migrations.btn.btn-primary(disabled="{{#if isMigrating}}disabled{{/if}}") {{_ 'start-all-migrations'}}
button.js-pause-all-migrations.btn.btn-warning(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'pause-all-migrations'}}
button.js-stop-all-migrations.btn.btn-danger(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'stop-all-migrations'}}
li
h3 {{_ 'board-operations'}}

View file

@ -8,7 +8,9 @@ import {
cronMigrationCurrentStep,
cronMigrationSteps,
cronIsMigrating,
cronJobs
cronJobs,
cronMigrationCurrentStepNum,
cronMigrationTotalSteps
} from '/imports/cronMigrationClient';
@ -27,6 +29,7 @@ BlazeComponent.extendComponent({
this.webhookSetting = new ReactiveVar(false);
this.attachmentSettings = new ReactiveVar(false);
this.cronSettings = new ReactiveVar(false);
this.migrationErrorsList = new ReactiveVar([]);
Meteor.subscribe('setting');
Meteor.subscribe('mailServer');
@ -36,6 +39,23 @@ BlazeComponent.extendComponent({
Meteor.subscribe('accessibilitySettings');
Meteor.subscribe('globalwebhooks');
Meteor.subscribe('lockoutSettings');
// Poll for migration errors
this.errorPollInterval = Meteor.setInterval(() => {
if (this.cronSettings.get()) {
Meteor.call('cron.getAllMigrationErrors', 50, (error, result) => {
if (!error && result) {
this.migrationErrorsList.set(result);
}
});
}
}, 5000); // Poll every 5 seconds
},
onDestroyed() {
if (this.errorPollInterval) {
Meteor.clearInterval(this.errorPollInterval);
}
},
@ -142,10 +162,40 @@ BlazeComponent.extendComponent({
return cronMigrationSteps.get() || [];
},
migrationStepsWithIndex() {
const steps = cronMigrationSteps.get() || [];
return steps.map((step, idx) => ({
...step,
index: idx + 1
}));
},
cronJobs() {
return cronJobs.get() || [];
},
migrationCurrentStepNum() {
return cronMigrationCurrentStepNum.get() || 0;
},
migrationTotalSteps() {
return cronMigrationTotalSteps.get() || 0;
},
migrationErrors() {
return this.migrationErrorsList ? this.migrationErrorsList.get() : [];
},
hasErrors() {
const errors = this.migrationErrors();
return errors && errors.length > 0;
},
formatDateTime(date) {
if (!date) return '';
return moment(date).format('YYYY-MM-DD HH:mm:ss');
},
setLoading(w) {
this.loading.set(w);
},
@ -187,20 +237,35 @@ BlazeComponent.extendComponent({
},
// Event handlers for cron settings
'click button.js-start-all-migrations'(event) {
'click button.js-start-migration'(event) {
event.preventDefault();
this.setLoading(true);
Meteor.call('cron.startAllMigrations', (error, result) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('migration-start-failed') + ': ' + error.reason);
} else {
alert(TAPi18n.__('migration-started'));
}
});
const selectedIndex = parseInt($('.js-migration-select').val() || '0', 10);
if (selectedIndex === 0) {
// Run all migrations
Meteor.call('cron.startAllMigrations', (error, result) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('migration-start-failed') + ': ' + error.reason);
} else {
alert(TAPi18n.__('migration-started'));
}
});
} else {
// Run specific migration
Meteor.call('cron.startSpecificMigration', selectedIndex - 1, (error, result) => {
this.setLoading(false);
if (error) {
alert(TAPi18n.__('migration-start-failed') + ': ' + error.reason);
} else {
alert(TAPi18n.__('migration-started'));
}
});
}
},
'click button.js-pause-all-migrations'(event) {
'click button.js-pause-migration'(event) {
event.preventDefault();
this.setLoading(true);
Meteor.call('cron.pauseAllMigrations', (error, result) => {
@ -213,7 +278,7 @@ BlazeComponent.extendComponent({
});
},
'click button.js-stop-all-migrations'(event) {
'click button.js-stop-migration'(event) {
event.preventDefault();
if (confirm(TAPi18n.__('migration-stop-confirm'))) {
this.setLoading(true);

View file

@ -7,6 +7,8 @@ export const cronMigrationCurrentStep = new ReactiveVar('');
export const cronMigrationSteps = new ReactiveVar([]);
export const cronIsMigrating = new ReactiveVar(false);
export const cronJobs = new ReactiveVar([]);
export const cronMigrationCurrentStepNum = new ReactiveVar(0);
export const cronMigrationTotalSteps = new ReactiveVar(0);
function fetchProgress() {
Meteor.call('cron.getMigrationProgress', (err, res) => {
@ -17,6 +19,8 @@ function fetchProgress() {
cronMigrationCurrentStep.set(res.currentStep || '');
cronMigrationSteps.set(res.steps || []);
cronIsMigrating.set(res.isMigrating || false);
cronMigrationCurrentStepNum.set(res.currentStepNum || 0);
cronMigrationTotalSteps.set(res.totalSteps || 0);
});
}

View file

@ -1389,9 +1389,31 @@
"cron-job-deleted": "Scheduled job deleted successfully",
"cron-job-pause-failed": "Failed to pause scheduled job",
"cron-job-paused": "Scheduled job paused successfully",
"cron-migration-errors": "Migration Errors",
"cron-migration-warnings": "Migration Warnings",
"cron-no-errors": "No errors to display",
"cron-error-severity": "Severity",
"cron-error-time": "Time",
"cron-error-message": "Error Message",
"cron-error-details": "Details",
"cron-clear-errors": "Clear All Errors",
"cron-retry-failed": "Retry Failed Migrations",
"cron-resume-paused": "Resume Paused Migrations",
"cron-errors-cleared": "All errors cleared successfully",
"cron-no-failed-migrations": "No failed migrations to retry",
"cron-no-paused-migrations": "No paused migrations to resume",
"cron-migrations-resumed": "Migrations resumed successfully",
"cron-migrations-retried": "Failed migrations retried successfully",
"complete": "Complete",
"idle": "Idle",
"filesystem-path-description": "Base path for file storage",
"gridfs-enabled": "GridFS Enabled",
"gridfs-enabled-description": "Use MongoDB GridFS for file storage",
"all-migrations": "All Migrations",
"select-migration": "Select Migration",
"start": "Start",
"pause": "Pause",
"stop": "Stop",
"migration-pause-failed": "Failed to pause migrations",
"migration-paused": "Migrations paused successfully",
"migration-progress": "Migration Progress",

View file

@ -10,6 +10,7 @@ import { Mongo } from 'meteor/mongo';
export const CronJobStatus = new Mongo.Collection('cronJobStatus');
export const CronJobSteps = new Mongo.Collection('cronJobSteps');
export const CronJobQueue = new Mongo.Collection('cronJobQueue');
export const CronJobErrors = new Mongo.Collection('cronJobErrors');
// Indexes for performance
if (Meteor.isServer) {
@ -29,6 +30,12 @@ if (Meteor.isServer) {
CronJobQueue._collection.createIndex({ priority: 1, createdAt: 1 });
CronJobQueue._collection.createIndex({ status: 1 });
CronJobQueue._collection.createIndex({ jobType: 1 });
// Index for job errors queries
CronJobErrors._collection.createIndex({ jobId: 1, createdAt: -1 });
CronJobErrors._collection.createIndex({ stepId: 1 });
CronJobErrors._collection.createIndex({ severity: 1 });
CronJobErrors._collection.createIndex({ createdAt: -1 });
});
}
@ -146,6 +153,59 @@ class CronJobStorage {
}, { sort: { stepIndex: 1 } }).fetch();
}
/**
* Save job error to persistent storage
*/
saveJobError(jobId, errorData) {
const now = new Date();
const { stepId, stepIndex, error, severity = 'error', context = {} } = errorData;
CronJobErrors.insert({
jobId,
stepId,
stepIndex,
errorMessage: typeof error === 'string' ? error : error.message || 'Unknown error',
errorStack: error.stack || null,
severity,
context,
createdAt: now
});
}
/**
* Get job errors from persistent storage
*/
getJobErrors(jobId, options = {}) {
const { limit = 100, severity = null } = options;
const query = { jobId };
if (severity) {
query.severity = severity;
}
return CronJobErrors.find(query, {
sort: { createdAt: -1 },
limit
}).fetch();
}
/**
* Get all recent errors across all jobs
*/
getAllRecentErrors(limit = 50) {
return CronJobErrors.find({}, {
sort: { createdAt: -1 },
limit
}).fetch();
}
/**
* Clear errors for a specific job
*/
clearJobErrors(jobId) {
return CronJobErrors.remove({ jobId });
}
/**
* Add job to queue
*/
@ -379,6 +439,7 @@ class CronJobStorage {
CronJobStatus.remove({});
CronJobSteps.remove({});
CronJobQueue.remove({});
CronJobErrors.remove({});
console.log('All cron job data cleared from storage');
return { success: true, message: 'All cron job data cleared' };

View file

@ -6,7 +6,13 @@
import { Meteor } from 'meteor/meteor';
import { SyncedCron } from 'meteor/quave:synced-cron';
import { ReactiveVar } from 'meteor/reactive-var';
import { cronJobStorage } from './cronJobStorage';
import { check, Match } from 'meteor/check';
import { ReactiveCache } from '/imports/reactiveCache';
import { cronJobStorage, CronJobStatus } from './cronJobStorage';
import Users from '/models/users';
import Boards from '/models/boards';
import { runEnsureValidSwimlaneIdsMigration } from './migrations/ensureValidSwimlaneIds';
// Server-side reactive variables for cron migration progress
export const cronMigrationProgress = new ReactiveVar(0);
@ -15,6 +21,8 @@ export const cronMigrationCurrentStep = new ReactiveVar('');
export const cronMigrationSteps = new ReactiveVar([]);
export const cronIsMigrating = new ReactiveVar(false);
export const cronJobs = new ReactiveVar([]);
export const cronMigrationCurrentStepNum = new ReactiveVar(0);
export const cronMigrationTotalSteps = new ReactiveVar(0);
// Board-specific operation tracking
export const boardOperations = new ReactiveVar(new Map());
@ -28,6 +36,7 @@ class CronMigrationManager {
this.isRunning = false;
this.jobProcessor = null;
this.processingInterval = null;
this.monitorInterval = null;
this.pausedJobs = new Map(); // Store paused job configs for per-job pause/resume
}
@ -135,6 +144,17 @@ class CronMigrationManager {
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'ensure-valid-swimlane-ids',
name: 'Validate Swimlane IDs',
description: 'Ensuring all cards and lists have valid swimlaneId references',
weight: 2,
completed: false,
progress: 0,
cronName: 'migration_swimlane_ids',
schedule: 'every 1 minute',
status: 'stopped'
},
{
id: 'add-sort-checklists',
name: 'Checklist Sorting',
@ -447,6 +467,18 @@ class CronMigrationManager {
async executeMigrationStep(jobId, stepIndex, stepData, stepId) {
const { name, duration } = stepData;
// Check if this is the star count migration that needs real implementation
if (stepId === 'denormalize-star-number-per-board') {
await this.executeDenormalizeStarCount(jobId, stepIndex, stepData);
return;
}
// Check if this is the swimlane validation migration
if (stepId === 'ensure-valid-swimlane-ids') {
await this.executeEnsureValidSwimlaneIds(jobId, stepIndex, stepData);
return;
}
// Simulate step execution with progress updates for other migrations
const progressSteps = 10;
for (let i = 0; i <= progressSteps; i++) {
@ -463,6 +495,173 @@ class CronMigrationManager {
}
}
/**
* Execute the denormalize star count migration
*/
async executeDenormalizeStarCount(jobId, stepIndex, stepData) {
try {
const { name } = stepData;
// Update progress: Starting
cronJobStorage.saveJobStep(jobId, stepIndex, {
progress: 0,
currentAction: 'Counting starred boards across all users...'
});
// Build a map of boardId -> star count
const starCounts = new Map();
// Get all users with starred boards
const users = Users.find(
{ 'profile.starredBoards': { $exists: true, $ne: [] } },
{ fields: { 'profile.starredBoards': 1 } }
).fetch();
// Update progress: Counting
cronJobStorage.saveJobStep(jobId, stepIndex, {
progress: 20,
currentAction: `Analyzing ${users.length} users with starred boards...`
});
// Count stars for each board
users.forEach(user => {
const starredBoards = (user.profile && user.profile.starredBoards) || [];
starredBoards.forEach(boardId => {
starCounts.set(boardId, (starCounts.get(boardId) || 0) + 1);
});
});
// Update progress: Updating boards
cronJobStorage.saveJobStep(jobId, stepIndex, {
progress: 50,
currentAction: `Updating star counts for ${starCounts.size} boards...`
});
// Update all boards with their star counts
let updatedCount = 0;
const totalBoards = starCounts.size;
for (const [boardId, count] of starCounts.entries()) {
try {
Boards.update(boardId, { $set: { stars: count } });
updatedCount++;
// Update progress periodically
if (updatedCount % 10 === 0 || updatedCount === totalBoards) {
const progress = 50 + Math.round((updatedCount / totalBoards) * 40);
cronJobStorage.saveJobStep(jobId, stepIndex, {
progress,
currentAction: `Updated ${updatedCount}/${totalBoards} boards...`
});
}
} catch (error) {
console.error(`Failed to update star count for board ${boardId}:`, error);
// Store error in database
cronJobStorage.saveJobError(jobId, {
stepId: 'denormalize-star-number-per-board',
stepIndex,
error,
severity: 'warning',
context: { boardId, operation: 'update_star_count' }
});
}
}
// Also set stars to 0 for boards that have no stars
cronJobStorage.saveJobStep(jobId, stepIndex, {
progress: 90,
currentAction: 'Initializing boards with no stars...'
});
const boardsWithoutStars = Boards.find(
{
$or: [
{ stars: { $exists: false } },
{ stars: null }
]
},
{ fields: { _id: 1 } }
).fetch();
boardsWithoutStars.forEach(board => {
// Only set to 0 if not already counted
if (!starCounts.has(board._id)) {
try {
Boards.update(board._id, { $set: { stars: 0 } });
} catch (error) {
console.error(`Failed to initialize star count for board ${board._id}:`, error);
// Store error in database
cronJobStorage.saveJobError(jobId, {
stepId: 'denormalize-star-number-per-board',
stepIndex,
error,
severity: 'warning',
context: { boardId: board._id, operation: 'initialize_star_count' }
});
}
}
});
// Complete
cronJobStorage.saveJobStep(jobId, stepIndex, {
progress: 100,
currentAction: `Migration complete: Updated ${updatedCount} boards with star counts`
});
console.log(`Star count migration completed: ${updatedCount} boards updated, ${boardsWithoutStars.length} initialized to 0`);
} catch (error) {
console.error('Error executing denormalize star count migration:', error);
// Store error in database
cronJobStorage.saveJobError(jobId, {
stepId: 'denormalize-star-number-per-board',
stepIndex,
error,
severity: 'error',
context: { operation: 'denormalize_star_count_migration' }
});
throw error;
}
}
/**
* Execute the ensure valid swimlane IDs migration
*/
async executeEnsureValidSwimlaneIds(jobId, stepIndex, stepData) {
try {
const { name } = stepData;
// Update progress: Starting
cronJobStorage.saveJobStep(jobId, stepIndex, {
progress: 0,
currentAction: 'Starting swimlane ID validation...'
});
// Run the migration function
const result = await runEnsureValidSwimlaneIdsMigration();
// Update progress: Complete
cronJobStorage.saveJobStep(jobId, stepIndex, {
progress: 100,
currentAction: `Migration complete: Fixed ${result.cardsFixed || 0} cards, ${result.listsFixed || 0} lists, rescued ${result.cardsRescued || 0} orphaned cards`
});
console.log(`Swimlane ID validation migration completed:`, result);
} catch (error) {
console.error('Error executing swimlane ID validation migration:', error);
// Store error in database
cronJobStorage.saveJobError(jobId, {
stepId: 'ensure-valid-swimlane-ids',
stepIndex,
error,
severity: 'error',
context: { operation: 'ensure_valid_swimlane_ids_migration' }
});
throw error;
}
}
/**
* Execute a board operation job
@ -697,7 +896,10 @@ class CronMigrationManager {
this.isRunning = true;
cronIsMigrating.set(true);
cronMigrationStatus.set('Adding migrations to job queue...');
cronMigrationStatus.set('Starting...');
cronMigrationProgress.set(0);
cronMigrationCurrentStepNum.set(0);
cronMigrationTotalSteps.set(0);
this.startTime = Date.now();
try {
@ -737,7 +939,7 @@ class CronMigrationManager {
});
}
cronMigrationStatus.set('Migrations added to queue. Processing will begin shortly...');
// Status will be updated by monitorMigrationProgress
// Start monitoring progress
this.monitorMigrationProgress();
@ -750,45 +952,119 @@ class CronMigrationManager {
}
}
/**
* Start a specific migration by index
*/
async startSpecificMigration(migrationIndex) {
if (this.isRunning) {
return;
}
const step = this.migrationSteps[migrationIndex];
if (!step) {
throw new Meteor.Error('invalid-migration', 'Migration not found');
}
this.isRunning = true;
cronIsMigrating.set(true);
cronMigrationStatus.set('Starting...');
cronMigrationProgress.set(0);
cronMigrationCurrentStepNum.set(1);
cronMigrationTotalSteps.set(1);
this.startTime = Date.now();
try {
// Remove cron job to prevent conflicts
try {
SyncedCron.remove(step.cronName);
} catch (error) {
// Ignore errors if cron job doesn't exist
}
// Add single migration step to the job queue
const jobId = `migration_${step.id}_${Date.now()}`;
cronJobStorage.addToQueue(jobId, 'migration', step.weight, {
stepId: step.id,
stepName: step.name,
stepDescription: step.description
});
// Save initial job status
cronJobStorage.saveJobStatus(jobId, {
jobType: 'migration',
status: 'pending',
progress: 0,
stepId: step.id,
stepName: step.name,
stepDescription: step.description
});
// Status will be updated by monitorMigrationProgress
// Start monitoring progress
this.monitorMigrationProgress();
} catch (error) {
console.error('Failed to start migration:', error);
cronMigrationStatus.set(`Failed to start migration: ${error.message}`);
cronIsMigrating.set(false);
this.isRunning = false;
}
}
/**
* Monitor migration progress
*/
monitorMigrationProgress() {
const monitorInterval = Meteor.setInterval(() => {
// Clear any existing monitor interval
if (this.monitorInterval) {
Meteor.clearInterval(this.monitorInterval);
}
this.monitorInterval = Meteor.setInterval(() => {
const stats = cronJobStorage.getQueueStats();
const incompleteJobs = cronJobStorage.getIncompleteJobs();
// Update progress
// Check if all migrations are completed first
const totalJobs = stats.total;
const completedJobs = stats.completed;
const progress = totalJobs > 0 ? Math.round((completedJobs / totalJobs) * 100) : 0;
if (stats.completed === totalJobs && totalJobs > 0 && stats.running === 0) {
// All migrations completed - immediately clear isMigrating to hide progress
cronIsMigrating.set(false);
cronMigrationStatus.set('All migrations completed successfully!');
cronMigrationProgress.set(0);
cronMigrationCurrentStep.set('');
cronMigrationCurrentStepNum.set(0);
cronMigrationTotalSteps.set(0);
// Clear status message after delay
setTimeout(() => {
cronMigrationStatus.set('');
}, 5000);
Meteor.clearInterval(this.monitorInterval);
this.monitorInterval = null;
return; // Exit early to avoid setting progress to 100%
}
// Update progress for active migrations
const progress = totalJobs > 0 ? Math.round((completedJobs / totalJobs) * 100) : 0;
cronMigrationProgress.set(progress);
cronMigrationTotalSteps.set(totalJobs);
const currentStepNum = completedJobs + (stats.running > 0 ? 1 : 0);
cronMigrationCurrentStepNum.set(currentStepNum);
// Update status
if (stats.running > 0) {
const runningJob = incompleteJobs.find(job => job.status === 'running');
if (runningJob) {
cronMigrationCurrentStep.set(runningJob.stepName || 'Processing migration...');
cronMigrationStatus.set(`Running: ${runningJob.stepName || 'Migration in progress'}`);
cronMigrationStatus.set(`Running: ${currentStepNum}/${totalJobs} ${runningJob.stepName || 'Migration in progress'}`);
cronMigrationCurrentStep.set('');
}
} else if (stats.pending > 0) {
cronMigrationStatus.set(`${stats.pending} migrations pending in queue`);
cronMigrationCurrentStep.set('Waiting for available resources...');
} else if (stats.completed === totalJobs && totalJobs > 0) {
// All migrations completed
cronMigrationStatus.set('All migrations completed successfully!');
cronMigrationProgress.set(100);
cronMigrationCurrentStep.set('');
// Clear status after delay
setTimeout(() => {
cronIsMigrating.set(false);
cronMigrationStatus.set('');
cronMigrationProgress.set(0);
}, 3000);
Meteor.clearInterval(monitorInterval);
}
}, 2000); // Check every 2 seconds
}
@ -1380,6 +1656,120 @@ class CronMigrationManager {
}
}
/**
* Pause all migrations
*/
pauseAllMigrations() {
this.isRunning = false;
cronIsMigrating.set(false);
cronMigrationStatus.set('Migrations paused');
// Update all pending jobs in queue to paused
const pendingJobs = cronJobStorage.getIncompleteJobs();
pendingJobs.forEach(job => {
if (job.status === 'pending' || job.status === 'running') {
cronJobStorage.updateQueueStatus(job.jobId, 'paused');
cronJobStorage.saveJobStatus(job.jobId, { status: 'paused' });
}
});
return { success: true, message: 'All migrations paused' };
}
/**
* Resume all paused migrations
*/
resumeAllMigrations() {
// Find all paused jobs and resume them
const pausedJobs = CronJobStatus.find({ status: 'paused' }).fetch();
if (pausedJobs.length === 0) {
return { success: false, message: 'No paused migrations to resume' };
}
pausedJobs.forEach(job => {
cronJobStorage.updateQueueStatus(job.jobId, 'pending');
cronJobStorage.saveJobStatus(job.jobId, { status: 'pending' });
});
this.isRunning = true;
cronIsMigrating.set(true);
cronMigrationStatus.set('Resuming migrations...');
// Restart monitoring
this.monitorMigrationProgress();
return { success: true, message: `Resumed ${pausedJobs.length} migrations` };
}
/**
* Retry failed migrations
*/
retryFailedMigrations() {
const failedJobs = CronJobStatus.find({ status: 'failed' }).fetch();
if (failedJobs.length === 0) {
return { success: false, message: 'No failed migrations to retry' };
}
// Clear errors for failed jobs
failedJobs.forEach(job => {
cronJobStorage.clearJobErrors(job.jobId);
cronJobStorage.updateQueueStatus(job.jobId, 'pending');
cronJobStorage.saveJobStatus(job.jobId, {
status: 'pending',
progress: 0,
error: null
});
});
if (!this.isRunning) {
this.isRunning = true;
cronIsMigrating.set(true);
cronMigrationStatus.set('Retrying failed migrations...');
this.monitorMigrationProgress();
}
return { success: true, message: `Retrying ${failedJobs.length} failed migrations` };
}
/**
* Get all migration errors
*/
getAllMigrationErrors(limit = 50) {
return cronJobStorage.getAllRecentErrors(limit);
}
/**
* Get errors for a specific job
*/
getJobErrors(jobId, options = {}) {
return cronJobStorage.getJobErrors(jobId, options);
}
/**
* Get migration stats including errors
*/
getMigrationStats() {
const queueStats = cronJobStorage.getQueueStats();
const allErrors = cronJobStorage.getAllRecentErrors(100);
const errorsByJob = {};
allErrors.forEach(error => {
if (!errorsByJob[error.jobId]) {
errorsByJob[error.jobId] = [];
}
errorsByJob[error.jobId].push(error);
});
return {
...queueStats,
totalErrors: allErrors.length,
errorsByJob,
recentErrors: allErrors.slice(0, 10)
};
}
}
// Export singleton instance
@ -1405,6 +1795,20 @@ Meteor.methods({
return cronMigrationManager.startAllMigrations();
},
'cron.startSpecificMigration'(migrationIndex) {
check(migrationIndex, Number);
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.startSpecificMigration(migrationIndex);
},
'cron.startJob'(cronName) {
const userId = this.userId;
if (!userId) {
@ -1511,10 +1915,95 @@ Meteor.methods({
status: cronMigrationStatus.get(),
currentStep: cronMigrationCurrentStep.get(),
steps: cronMigrationSteps.get(),
isMigrating: cronIsMigrating.get()
isMigrating: cronIsMigrating.get(),
currentStepNum: cronMigrationCurrentStepNum.get(),
totalSteps: cronMigrationTotalSteps.get()
};
},
'cron.pauseAllMigrations'() {
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.pauseAllMigrations();
},
'cron.resumeAllMigrations'() {
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.resumeAllMigrations();
},
'cron.retryFailedMigrations'() {
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.retryFailedMigrations();
},
'cron.getAllMigrationErrors'(limit = 50) {
check(limit, Match.Optional(Number));
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.getAllMigrationErrors(limit);
},
'cron.getJobErrors'(jobId, options = {}) {
check(jobId, String);
check(options, Match.Optional(Object));
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.getJobErrors(jobId, options);
},
'cron.getMigrationStats'() {
const userId = this.userId;
if (!userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const user = ReactiveCache.getUser(userId);
if (!user || !user.isAdmin) {
throw new Meteor.Error('not-authorized', 'Admin access required');
}
return cronMigrationManager.getMigrationStats();
},
'cron.startBoardOperation'(boardId, operationType, operationData) {
const userId = this.userId;
if (!userId) {
@ -1747,6 +2236,12 @@ Meteor.methods({
throw new Meteor.Error('not-authorized', 'Admin access required');
}
// Clear monitor interval first to prevent status override
if (cronMigrationManager.monitorInterval) {
Meteor.clearInterval(cronMigrationManager.monitorInterval);
cronMigrationManager.monitorInterval = null;
}
// Stop all running and pending jobs
const incompleteJobs = cronJobStorage.getIncompleteJobs();
incompleteJobs.forEach(job => {
@ -1757,11 +2252,19 @@ Meteor.methods({
});
});
// Reset migration state
// Reset migration state immediately
cronMigrationManager.isRunning = false;
cronIsMigrating.set(false);
cronMigrationStatus.set('All migrations stopped');
cronMigrationProgress.set(0);
cronMigrationCurrentStep.set('');
cronMigrationCurrentStepNum.set(0);
cronMigrationTotalSteps.set(0);
cronMigrationStatus.set('All migrations stopped');
// Clear status message after delay
setTimeout(() => {
cronMigrationStatus.set('');
}, 3000);
return { success: true, message: 'All migrations stopped' };
},

View file

@ -12,6 +12,9 @@
// Helper collection to track migrations - must be defined first
const Migrations = new Mongo.Collection('migrations');
// DISABLED: This migration now runs from Admin Panel / Cron / Run All Migrations
// Instead of running automatically on startup
/*
Meteor.startup(() => {
// Only run on server
if (!Meteor.isServer) return;
@ -26,11 +29,16 @@ Meteor.startup(() => {
}
console.log(`Running migration: ${MIGRATION_NAME} v${MIGRATION_VERSION}`);
*/
/**
* Get or create a "Rescued Data" swimlane for a board
*/
function getOrCreateRescuedSwimlane(boardId) {
// Export migration functions for use by cron migration manager
export const MIGRATION_NAME = 'ensure-valid-swimlane-ids';
export const MIGRATION_VERSION = 1;
/**
* Get or create a "Rescued Data" swimlane for a board
*/
function getOrCreateRescuedSwimlane(boardId) {
const board = Boards.findOne(boardId);
if (!board) return null;
@ -243,6 +251,72 @@ Meteor.startup(() => {
});
}
// Exported function to run the migration from cron
export function runEnsureValidSwimlaneIdsMigration() {
const existingMigration = Migrations.findOne({ name: MIGRATION_NAME });
if (existingMigration && existingMigration.version >= MIGRATION_VERSION) {
console.log(`Migration ${MIGRATION_NAME} already completed`);
return { alreadyCompleted: true, ...existingMigration.results };
}
console.log(`Running migration: ${MIGRATION_NAME} v${MIGRATION_VERSION}`);
try {
// Run all fix operations
const cardResults = fixCardsWithoutSwimlaneId();
const listResults = fixListsWithoutSwimlaneId();
const rescueResults = rescueOrphanedCards();
console.log('Migration results:');
console.log(`- Fixed ${cardResults.fixedCount} cards without swimlaneId`);
console.log(`- Fixed ${listResults.fixedCount} lists without swimlaneId`);
console.log(`- Rescued ${rescueResults.rescuedCount} orphaned cards`);
// Record migration completion
Migrations.upsert(
{ name: MIGRATION_NAME },
{
$set: {
name: MIGRATION_NAME,
version: MIGRATION_VERSION,
completedAt: new Date(),
results: {
cardsFixed: cardResults.fixedCount,
listsFixed: listResults.fixedCount,
cardsRescued: rescueResults.rescuedCount,
},
},
}
);
console.log(`Migration ${MIGRATION_NAME} completed successfully`);
return {
success: true,
cardsFixed: cardResults.fixedCount,
listsFixed: listResults.fixedCount,
cardsRescued: rescueResults.rescuedCount,
};
} catch (error) {
console.error(`Migration ${MIGRATION_NAME} failed:`, error);
throw error;
}
}
// Install validation hooks on startup (always run these for data integrity)
Meteor.startup(() => {
if (!Meteor.isServer) return;
try {
addSwimlaneIdValidationHooks();
console.log('SwimlaneId validation hooks installed');
} catch (error) {
console.error('Failed to install swimlaneId validation hooks:', error);
}
});
/*
// OLD AUTO-RUN CODE - DISABLED
try {
// Run all fix operations
const cardResults = fixCardsWithoutSwimlaneId();
@ -284,3 +358,4 @@ Meteor.startup(() => {
console.error('Failed to install swimlaneId validation hooks:', error);
}
});
*/