mirror of
https://github.com/wekan/wekan.git
synced 2026-11-06 12:45:03 -05:00
Motivated by the 10.16-10.21 reports where, after an upgrade, the login page showed "Must be logged in" or sat on the logo + "Loading, please wait." spinner, pages took 5+ minutes, All Boards counts stayed 0 and i18n showed raw keys — the FerretDB v1 (SQLite) process pegged ~2 cores for a long time under WeKan's poll load, i.e. a migration/repair or a saturated backend, with no way to see it without the Admin Panel. Server-side, independent of any client / progress page: - server/startup/repairBoardsOnStartup.js runs the shared board data-repairs once (version-gated, background, 30s after boot) so corrupted data is fixed even if no user opens a board and nobody watches a progress page. - The MongoDB <-> SQLite text migration and the startup repair now PERSIST their progress to the text_migration_status collection (models/textMigrationStatus.js), so a separate process can see "a migration/repair is in progress" (an in-memory object could not). Status overview (server/lib/systemStatus.js), shown in Admin Panel / Problems -> Summary and via the method systemStatusReport (admin-gated, server/methods/ systemStatus.js): - everything in progress (text migration, board repair, recovery, cron migration, attachment migration) aggregated from the persisted status collections; - detected problems (broken cards; failing login-page checks); - login-page "Must be logged in" / "Loading, please wait" cause checklist (migration in progress, ROOT_URL, LDAP, Sandstorm) — models/lib/loginProblems.js; - a CPU detail area that calls out a pegged ferretdb process and the mitigation. Snap: `snap run wekan.problems` (snap-src/bin/wekan-problems[.mjs], snapcraft app `problems`) prints the same overview as text — problems, migrations/repairs in progress, login checks — so an admin with only server access can see it WITHOUT Admin Panel access. Sub-commands: migrations | login | broken-cards | cpu | help. Pure helpers models/lib/loginProblems.js and models/lib/problemsOverview.js are unit-tested; tests/problemsStatus.test.cjs (14 checks) also source-guards the server hub, method, startup pass, migration persistence and snap command. Thanks to xet7 !
161 lines
7.0 KiB
JavaScript
161 lines
7.0 KiB
JavaScript
import { Meteor } from 'meteor/meteor';
|
|
import { MongoInternals } from 'meteor/mongo';
|
|
import { MongoClient } from 'mongodb';
|
|
import { ReactiveCache } from '/imports/reactiveCache';
|
|
import { repairAllBoards } from '/server/lib/repairBoardData';
|
|
import { setTextMigrationStatus } from '/server/lib/systemStatus';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Admin Panel / Attachments: migrate the TEXT-based data (everything that is NOT
|
|
// attachments/avatars — those are files on the filesystem) between MongoDB and
|
|
// FerretDB v1 (SQLite), in either direction.
|
|
//
|
|
// WeKan is connected to one database at a time (its MONGO_URL). This copies the
|
|
// text collections from that live database into the OTHER database over a second
|
|
// driver connection (both MongoDB and FerretDB v1 speak the MongoDB wire
|
|
// protocol, so the same Node driver talks to both). After migrating you point
|
|
// MONGO_URL at the other database (or, on the snap, `snap set wekan
|
|
// database=ferretdb` / `=mongodb`) and restart.
|
|
//
|
|
// Target URLs (both must be running and reachable):
|
|
// toFerretDB -> WEKAN_FERRETDB_URL (default mongodb://127.0.0.1:27018/wekan)
|
|
// toMongoDB -> WEKAN_MONGODB_URL (default mongodb://127.0.0.1:27019/wekan)
|
|
//
|
|
// It copies text data only; attachments/avatars stay on the filesystem and are
|
|
// unaffected. Idempotent (documents are upserted by _id), so re-running is safe.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// Collections that hold FILE data (attachments/avatars, incl. legacy GridFS/CFS
|
|
// metadata) — NOT text data, so they are skipped.
|
|
const FILE_COLLECTIONS = new Set([
|
|
'attachments', 'avatars',
|
|
'cfs.attachments.filerecord', 'cfs.avatars.filerecord',
|
|
'cfs_gridfs.attachments.files', 'cfs_gridfs.attachments.chunks',
|
|
'cfs_gridfs.avatars.files', 'cfs_gridfs.avatars.chunks',
|
|
]);
|
|
|
|
const BATCH = 200;
|
|
|
|
// Shared progress the client polls via migrateTextDatabaseStatus(). The client
|
|
// mirrors this into the shared blue, Product-name-branded migration-progress
|
|
// dashboard (client/components/settings/migrationProgress). The `repair*` fields
|
|
// track the board data-repairs run on the live database before the copy.
|
|
const progress = {
|
|
running: false, direction: '', phase: 'idle',
|
|
collection: '', collDone: 0, collTotal: 0,
|
|
collectionsDone: 0, collectionsTotal: 0,
|
|
boardsDone: 0, boardsTotal: 0, repaired: null,
|
|
startedAt: null, finishedAt: null, success: null, error: '',
|
|
};
|
|
|
|
function targetUrlFor(direction) {
|
|
if (direction === 'toFerretDB') {
|
|
return process.env.WEKAN_FERRETDB_URL || 'mongodb://127.0.0.1:27018/wekan';
|
|
}
|
|
if (direction === 'toMongoDB') {
|
|
return process.env.WEKAN_MONGODB_URL || 'mongodb://127.0.0.1:27019/wekan';
|
|
}
|
|
throw new Meteor.Error('bad-direction', "direction must be 'toFerretDB' or 'toMongoDB'");
|
|
}
|
|
|
|
async function copyTextData(direction) {
|
|
progress.running = true;
|
|
progress.direction = direction;
|
|
progress.phase = 'connecting';
|
|
progress.collection = ''; progress.collDone = 0; progress.collTotal = 0;
|
|
progress.collectionsDone = 0; progress.collectionsTotal = 0;
|
|
progress.boardsDone = 0; progress.boardsTotal = 0; progress.repaired = null;
|
|
progress.startedAt = new Date().toISOString();
|
|
progress.finishedAt = null; progress.success = null; progress.error = '';
|
|
await setTextMigrationStatus(progress);
|
|
|
|
const srcDb = MongoInternals.defaultRemoteCollectionDriver().mongo.db;
|
|
const targetUrl = targetUrlFor(direction);
|
|
let client;
|
|
try {
|
|
// First repair the LIVE (source) database so the copy carries clean data into
|
|
// the target. Same shared repair set as board open (#6484 list-unbind +
|
|
// missing-swimlane / orphaned cards). Idempotent, so it is safe either
|
|
// direction. Failures per board are logged and skipped inside repairAllBoards.
|
|
progress.phase = 'repairing';
|
|
await setTextMigrationStatus(progress);
|
|
await repairAllBoards((done, total, totals) => {
|
|
progress.boardsDone = done;
|
|
progress.boardsTotal = total;
|
|
progress.repaired = { ...totals };
|
|
if (done === total || done % 25 === 0) setTextMigrationStatus(progress);
|
|
});
|
|
|
|
client = await MongoClient.connect(targetUrl);
|
|
const tgtDb = client.db(
|
|
new URL(targetUrl.replace('mongodb://', 'http://')).pathname.slice(1) || 'wekan',
|
|
);
|
|
|
|
const names = (await srcDb.listCollections().toArray())
|
|
.map(c => c.name)
|
|
.filter(n => !FILE_COLLECTIONS.has(n) && !n.startsWith('system.'));
|
|
progress.collectionsTotal = names.length;
|
|
progress.phase = 'migrating';
|
|
await setTextMigrationStatus(progress);
|
|
|
|
for (const name of names) {
|
|
progress.collection = name;
|
|
const src = srcDb.collection(name);
|
|
const tgt = tgtDb.collection(name);
|
|
progress.collTotal = await src.countDocuments().catch(() => 0);
|
|
progress.collDone = 0;
|
|
await setTextMigrationStatus(progress);
|
|
|
|
const cursor = src.find({});
|
|
let batch = [];
|
|
const flush = async () => {
|
|
if (!batch.length) return;
|
|
const ops = batch.map(d => ({ replaceOne: { filter: { _id: d._id }, replacement: d, upsert: true } }));
|
|
try { await tgt.bulkWrite(ops, { ordered: false }); }
|
|
catch (e) { /* fall back per-doc */ for (const d of batch) { try { await tgt.replaceOne({ _id: d._id }, d, { upsert: true }); } catch (_) {} } }
|
|
progress.collDone += batch.length;
|
|
batch = [];
|
|
};
|
|
for await (const doc of cursor) {
|
|
batch.push(doc);
|
|
if (batch.length >= BATCH) await flush();
|
|
}
|
|
await flush();
|
|
await cursor.close();
|
|
progress.collectionsDone += 1;
|
|
}
|
|
|
|
progress.phase = 'completed';
|
|
progress.success = true;
|
|
} catch (e) {
|
|
progress.phase = 'error';
|
|
progress.success = false;
|
|
progress.error = String(e && e.message ? e.message : e).slice(0, 500);
|
|
console.error('[migrateTextDatabase]', e);
|
|
} finally {
|
|
if (client) { try { await client.close(); } catch (_) {} }
|
|
progress.running = false;
|
|
progress.finishedAt = new Date().toISOString();
|
|
await setTextMigrationStatus(progress);
|
|
}
|
|
}
|
|
|
|
Meteor.methods({
|
|
async migrateTextDatabaseStatus() {
|
|
const user = await ReactiveCache.getCurrentUser();
|
|
if (!user || !user.isAdmin) return false;
|
|
return { ...progress };
|
|
},
|
|
|
|
async migrateTextDatabase(direction) {
|
|
const user = await ReactiveCache.getCurrentUser();
|
|
if (!user || !user.isAdmin) throw new Meteor.Error('not-authorized');
|
|
if (progress.running) throw new Meteor.Error('already-running', 'A database migration is already in progress.');
|
|
// Validate direction up front (throws on bad value).
|
|
targetUrlFor(direction);
|
|
// Run in the background; the client polls migrateTextDatabaseStatus().
|
|
copyTextData(direction);
|
|
return { started: true, direction };
|
|
},
|
|
});
|