mirror of
https://github.com/wekan/wekan.git
synced 2026-01-23 17:56:09 +01:00
Fix DB migration from 8.19 to 8.21 stuck forever.
Thanks to MaccabeeY and xet7 ! Fixes #6078
This commit is contained in:
parent
e177bf54e2
commit
a31a615da6
9 changed files with 869 additions and 71 deletions
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue