trilium/src/services/consistency_checks.js

502 lines
16 KiB
JavaScript
Raw Normal View History

2017-12-15 11:16:26 +08:00
"use strict";
const sql = require('./sql');
const sqlInit = require('./sql_init');
2017-12-15 11:16:26 +08:00
const log = require('./log');
const messagingService = require('./messaging');
const syncMutexService = require('./sync_mutex');
const noteFulltextService = require('./note_fulltext');
const repository = require('./repository');
const cls = require('./cls');
const syncTableService = require('./sync_table');
const Branch = require('../entities/branch');
2017-12-15 11:16:26 +08:00
let unrecoverableConsistencyErrors = false;
2019-02-02 16:26:57 +08:00
let fixedIssues = false;
2017-12-15 11:16:26 +08:00
async function findIssues(query, errorCb) {
const results = await sql.getRows(query);
2017-12-24 02:16:18 +08:00
for (const res of results) {
2019-02-02 17:38:33 +08:00
logError(errorCb(res));
2017-12-15 11:16:26 +08:00
unrecoverableConsistencyErrors = true;
2017-12-15 11:16:26 +08:00
}
return results;
2017-12-15 11:16:26 +08:00
}
async function findAndFixIssues(query, fixerCb) {
const results = await sql.getRows(query);
for (const res of results) {
await fixerCb(res);
2019-02-02 16:26:57 +08:00
fixedIssues = true;
}
return results;
}
async function checkTreeCycles() {
2018-01-02 08:41:22 +08:00
const childToParents = {};
2018-03-25 09:39:15 +08:00
const rows = await sql.getRows("SELECT noteId, parentNoteId FROM branches WHERE isDeleted = 0");
2018-01-02 08:41:22 +08:00
for (const row of rows) {
2018-01-29 08:30:14 +08:00
const childNoteId = row.noteId;
const parentNoteId = row.parentNoteId;
2018-01-02 08:41:22 +08:00
2018-10-22 03:37:34 +08:00
childToParents[childNoteId] = childToParents[childNoteId] || [];
2018-01-02 08:41:22 +08:00
childToParents[childNoteId].push(parentNoteId);
}
function checkTreeCycle(noteId, path) {
2018-01-02 08:41:22 +08:00
if (noteId === 'root') {
return;
}
if (!childToParents[noteId] || childToParents[noteId].length === 0) {
2019-02-02 16:26:57 +08:00
logError(`No parents found for note ${noteId}`);
unrecoverableConsistencyErrors = true;
return;
}
2018-01-02 08:41:22 +08:00
for (const parentNoteId of childToParents[noteId]) {
if (path.includes(parentNoteId)) {
2019-02-02 16:26:57 +08:00
logError(`Tree cycle detected at parent-child relationship: ${parentNoteId} - ${noteId}, whole path: ${path}`);
unrecoverableConsistencyErrors = true;
2018-01-02 08:41:22 +08:00
}
else {
const newPath = path.slice();
newPath.push(noteId);
checkTreeCycle(parentNoteId, newPath);
2018-01-02 08:41:22 +08:00
}
}
}
const noteIds = Object.keys(childToParents);
for (const noteId of noteIds) {
checkTreeCycle(noteId, []);
2018-01-02 08:41:22 +08:00
}
2018-10-22 03:37:34 +08:00
if (childToParents['root'].length !== 1 || childToParents['root'][0] !== 'none') {
2019-02-02 16:26:57 +08:00
logError('Incorrect root parent: ' + JSON.stringify(childToParents['root']));
unrecoverableConsistencyErrors = true;
2018-10-22 03:37:34 +08:00
}
2018-01-02 08:41:22 +08:00
}
2019-02-02 17:38:33 +08:00
async function findBrokenReferenceIssues() {
await findIssues(`
SELECT branchId, branches.noteId
FROM branches LEFT JOIN notes USING(noteId)
WHERE branches.isDeleted = 0 AND notes.noteId IS NULL`,
2019-02-02 17:38:33 +08:00
({branchId, noteId}) => `Branch ${branchId} references missing note ${noteId}`);
2018-11-13 06:34:22 +08:00
2019-02-02 17:38:33 +08:00
await findIssues(`
SELECT branchId, branches.noteId AS parentNoteId
FROM branches LEFT JOIN notes ON notes.noteId = branches.parentNoteId
WHERE branches.isDeleted = 0 AND branches.branchId != 'root' AND notes.noteId IS NULL`,
2019-02-02 17:38:33 +08:00
({branchId, noteId}) => `Branch ${branchId} references missing parent note ${noteId}`);
2019-02-02 17:38:33 +08:00
await findIssues(`
SELECT attributeId, attributes.noteId
FROM attributes LEFT JOIN notes USING(noteId)
WHERE attributes.isDeleted = 0 AND notes.noteId IS NULL`,
2019-02-02 17:38:33 +08:00
({attributeId, noteId}) => `Attribute ${attributeId} references missing source note ${noteId}`);
2019-02-02 17:38:33 +08:00
// empty targetNoteId for relations is a special fixable case so not covered here
await findIssues(`
2019-02-03 23:22:45 +08:00
SELECT attributeId, attributes.value AS noteId
2019-02-02 17:38:33 +08:00
FROM attributes LEFT JOIN notes ON notes.noteId = attributes.value
WHERE attributes.isDeleted = 0 AND attributes.type = 'relation'
AND attributes.value != '' AND notes.noteId IS NULL`,
2019-02-02 17:38:33 +08:00
({attributeId, noteId}) => `Relation ${attributeId} references missing note ${noteId}`);
2019-02-02 17:38:33 +08:00
await findIssues(`
SELECT linkId, links.noteId
FROM links LEFT JOIN notes USING(noteId)
WHERE links.isDeleted = 0 AND notes.noteId IS NULL`,
2019-02-02 17:38:33 +08:00
({linkId, noteId}) => `Link ${linkId} references missing source note ${noteId}`);
2019-02-02 17:38:33 +08:00
await findIssues(`
SELECT linkId, links.noteId
FROM links LEFT JOIN notes ON notes.noteId = links.targetNoteId
WHERE links.isDeleted = 0 AND notes.noteId IS NULL`,
2019-02-02 17:38:33 +08:00
({linkId, noteId}) => `Link ${linkId} references missing target note ${noteId}`);
2019-02-02 17:38:33 +08:00
await findIssues(`
SELECT noteRevisionId, note_revisions.noteId
FROM note_revisions LEFT JOIN notes USING(noteId)
WHERE notes.noteId IS NULL`,
({noteRevisionId, noteId}) => `Note revision ${noteRevisionId} references missing note ${noteId}`);
}
2019-02-02 18:26:27 +08:00
async function findExistencyIssues() {
2019-02-02 17:38:33 +08:00
// principle for fixing inconsistencies is that if the note itself is deleted (isDeleted=true) then all related entities should be also deleted (branches, links, attributes)
// but if note is not deleted, then at least one branch should exist.
// the order here is important - first we might need to delete inconsistent branches and after that
// another check might create missing branch
await findAndFixIssues(`
SELECT
branchId, noteId
FROM
branches
JOIN notes USING(noteId)
WHERE
notes.isDeleted = 1
AND branches.isDeleted = 0`,
async ({branchId, noteId}) => {
const branch = await repository.getBranch(branchId);
branch.isDeleted = true;
await branch.save();
logFix(`Branch ${branchId} has been deleted since associated note ${noteId} is deleted.`);
});
await findAndFixIssues(`
SELECT
branchId, parentNoteId
FROM
branches
JOIN notes AS parentNote ON parentNote.noteId = branches.parentNoteId
WHERE
parentNote.isDeleted = 1
AND branches.isDeleted = 0
`, async ({branchId, parentNoteId}) => {
const branch = await repository.getBranch(branchId);
branch.isDeleted = true;
await branch.save();
logFix(`Branch ${branchId} has been deleted since associated parent note ${parentNoteId} is deleted.`);
});
await findAndFixIssues(`
SELECT
DISTINCT notes.noteId
FROM
notes
LEFT JOIN branches ON notes.noteId = branches.noteId AND branches.isDeleted = 0
WHERE
notes.isDeleted = 0
AND branches.branchId IS NULL
`, async ({noteId}) => {
const branch = await new Branch({
parentNoteId: 'root',
noteId: noteId,
prefix: 'recovered'
}).save();
2019-02-02 16:26:57 +08:00
logFix(`Created missing branch ${branch.branchId} for note ${noteId}`);
});
2019-01-19 16:57:51 +08:00
// there should be a unique relationship between note and its parent
2019-02-02 17:38:33 +08:00
await findAndFixIssues(`
SELECT
noteId, parentNoteId
FROM
branches
WHERE
branches.isDeleted = 0
GROUP BY
branches.parentNoteId,
branches.noteId
HAVING
COUNT(*) > 1`,
async ({noteId, parentNoteId}) => {
const branches = await repository.getEntities(`SELECT * FROM branches WHERE noteId = ? and parentNoteId = ? and isDeleted = 1`, [noteId, parentNoteId]);
// it's not necessarily "original" branch, it's just the only one which will survive
const origBranch = branches.get(0);
// delete all but the first branch
for (const branch of branches.slice(1)) {
2019-02-02 17:38:33 +08:00
branch.isDeleted = true;
await branch.save();
logFix(`Removing branch ${branch.branchId} since it's parent-child duplicate of branch ${origBranch.branchId}`);
}
});
2019-02-02 17:38:33 +08:00
}
2019-02-02 18:26:27 +08:00
async function findLogicIssues() {
await findIssues( `
2019-02-02 17:38:33 +08:00
SELECT noteId, type
FROM notes
WHERE
isDeleted = 0
AND type NOT IN ('text', 'code', 'render', 'file', 'image', 'search', 'relation-map')`,
2019-02-02 16:26:57 +08:00
({noteId, type}) => `Note ${noteId} has invalid type=${type}`);
2018-01-21 10:56:03 +08:00
await findIssues(`
2019-02-02 17:38:33 +08:00
SELECT noteId
FROM notes
JOIN note_contents USING(noteId)
2018-11-20 06:11:36 +08:00
WHERE
isDeleted = 0
AND content IS NULL`,
2019-02-02 16:26:57 +08:00
({noteId}) => `Note ${noteId} content is null even though it is not deleted`);
2018-11-20 06:11:36 +08:00
await findIssues(`
2019-02-02 17:38:33 +08:00
SELECT parentNoteId
FROM
2018-03-25 09:39:15 +08:00
branches
JOIN notes ON notes.noteId = branches.parentNoteId
WHERE
notes.isDeleted = 0
AND notes.type == 'search'
AND branches.isDeleted = 0`,
2019-02-02 16:26:57 +08:00
({parentNoteId}) => `Search note ${parentNoteId} has children`);
2019-02-02 17:38:33 +08:00
await findAndFixIssues(`
SELECT attributeId
FROM attributes
WHERE
isDeleted = 0
AND type = 'relation'
AND value = ''`,
2019-02-02 18:26:27 +08:00
async ({attributeId}) => {
const relation = await repository.getAttribute(attributeId);
relation.isDeleted = true;
await relation.save();
2019-02-02 17:38:33 +08:00
2019-02-02 18:26:27 +08:00
logFix(`Removed relation ${relation.attributeId} of name "${relation.name} with empty target.`);
});
await findIssues(`
SELECT
attributeId,
type
2019-02-02 17:38:33 +08:00
FROM attributes
WHERE
isDeleted = 0
AND type != 'label'
AND type != 'label-definition'
AND type != 'relation'
AND type != 'relation-definition'`,
2019-02-02 16:26:57 +08:00
({attributeId, type}) => `Attribute ${attributeId} has invalid type '${type}'`);
2019-02-02 16:26:57 +08:00
await findAndFixIssues(`
SELECT
attributeId,
attributes.noteId
FROM
attributes
JOIN notes ON attributes.noteId = notes.noteId
WHERE
attributes.isDeleted = 0
AND notes.isDeleted = 1`,
async ({attributeId, noteId}) => {
const attribute = await repository.getAttribute(attributeId);
attribute.isDeleted = true;
await attribute.save();
logFix(`Removed attribute ${attributeId} because owning note ${noteId} is also deleted.`);
});
2019-02-02 17:38:33 +08:00
await findAndFixIssues(`
SELECT
attributeId,
2019-02-02 17:38:33 +08:00
attributes.value AS targetNoteId
FROM
attributes
2019-02-02 17:38:33 +08:00
JOIN notes ON attributes.value = notes.noteId
WHERE
attributes.type = 'relation'
2019-02-02 17:38:33 +08:00
AND attributes.isDeleted = 0
AND notes.isDeleted = 1`,
async ({attributeId, targetNoteId}) => {
const attribute = await repository.getAttribute(attributeId);
attribute.isDeleted = true;
await attribute.save();
logFix(`Removed attribute ${attributeId} because target note ${targetNoteId} is also deleted.`);
});
await findIssues(`
2019-02-02 17:38:33 +08:00
SELECT linkId
FROM links
WHERE type NOT IN ('image', 'hyper', 'relation-map')`,
2019-02-02 16:26:57 +08:00
({linkId, type}) => `Link ${linkId} has invalid type '${type}'`);
await findAndFixIssues(`
2019-02-02 17:38:33 +08:00
SELECT
linkId,
2019-02-02 17:38:33 +08:00
links.noteId AS sourceNoteId
FROM
links
2019-02-02 17:38:33 +08:00
JOIN notes AS sourceNote ON sourceNote.noteId = links.noteId
WHERE
links.isDeleted = 0
2019-02-02 17:38:33 +08:00
AND sourceNote.isDeleted = 1`,
async ({linkId, sourceNoteId}) => {
const link = await repository.getLink(linkId);
link.isDeleted = true;
await link.save();
2019-02-02 17:38:33 +08:00
logFix(`Removed link ${linkId} because source note ${sourceNoteId} is also deleted.`);
});
await findAndFixIssues(`
SELECT
linkId,
2019-02-02 17:38:33 +08:00
links.targetNoteId
FROM
links
2019-02-02 17:38:33 +08:00
JOIN notes AS targetNote ON targetNote.noteId = links.targetNoteId
WHERE
links.isDeleted = 0
2019-02-02 17:38:33 +08:00
AND targetNote.isDeleted = 1`,
async ({linkId, targetNoteId}) => {
2019-02-02 18:26:27 +08:00
const link = await repository.getLink(linkId);
link.isDeleted = true;
await link.save();
2019-02-02 17:38:33 +08:00
2019-02-02 18:26:27 +08:00
logFix(`Removed link ${linkId} because target note ${targetNoteId} is also deleted.`);
});
2019-03-30 05:08:04 +08:00
// this doesn't try to find notes for which the fulltext doesn't exist at all - reason is the "archived" label
// which is inheritable and not easy to filter out such rows in consistency check which would mean that it would
// find some false positives.
await findAndFixIssues(`
SELECT
noteId
FROM
notes
JOIN note_contents USING(noteId)
2019-03-30 05:08:04 +08:00
JOIN note_fulltext USING(noteId)
WHERE
notes.isDeleted = 0
2019-03-30 05:08:04 +08:00
AND notes.isProtected = 0
AND (note_fulltext.noteId IS NULL
OR note_fulltext.titleHash != notes.hash
OR note_fulltext.contentHash != note_contents.hash)`,
async ({noteId}) => {
noteFulltextService.triggerNoteFulltextUpdate(noteId);
2019-03-30 05:08:04 +08:00
logFix(`Triggered fulltext update of note ${noteId} since it was out of sync.`);
});
await findAndFixIssues(`
SELECT
noteId
FROM
notes
JOIN note_fulltext USING(noteId)
WHERE
(notes.isDeleted = 1 OR notes.isProtected = 1)`,
async ({noteId}) => {
noteFulltextService.triggerNoteFulltextUpdate(noteId);
logFix(`Triggered fulltext update of note ${noteId} since it was out of sync.`);
});
2019-02-02 18:26:27 +08:00
}
2019-02-02 18:26:27 +08:00
async function runSyncRowChecks(entityName, key) {
await findAndFixIssues(`
SELECT
${key} as entityId
FROM
${entityName}
LEFT JOIN sync ON sync.entityName = '${entityName}' AND entityId = ${key}
WHERE
sync.id IS NULL AND ` + (entityName === 'options' ? 'isSynced = 1' : '1'),
async ({entityId}) => {
await syncTableService.addEntitySync(entityName, entityId);
logFix(`Created missing sync record entityName=${entityName}, entityId=${entityId}`);
});
await findAndFixIssues(`
SELECT
id, entityId
FROM
sync
LEFT JOIN ${entityName} ON entityId = ${key}
WHERE
sync.entityName = '${entityName}'
AND ${key} IS NULL`,
async ({id, entityId}) => {
await sql.execute("DELETE FROM sync WHERE entityName = ? AND entityId = ?", [entityName, entityId]);
logFix(`Deleted extra sync record id=${id}, entityName=${entityName}, entityId=${entityId}`);
});
}
async function findSyncRowsIssues() {
await runSyncRowChecks("notes", "noteId");
await runSyncRowChecks("note_revisions", "noteRevisionId");
await runSyncRowChecks("branches", "branchId");
await runSyncRowChecks("recent_notes", "branchId");
await runSyncRowChecks("attributes", "attributeId");
await runSyncRowChecks("api_tokens", "apiTokenId");
await runSyncRowChecks("options", "name");
2019-02-02 18:26:27 +08:00
}
async function runAllChecks() {
unrecoverableConsistencyErrors = false;
fixedIssues = false;
2019-02-02 18:26:27 +08:00
await findBrokenReferenceIssues();
await findExistencyIssues();
await findLogicIssues();
await findSyncRowsIssues();
if (unrecoverableConsistencyErrors) {
2018-01-02 08:41:22 +08:00
// we run this only if basic checks passed since this assumes basic data consistency
await checkTreeCycles();
2018-01-02 08:41:22 +08:00
}
return !unrecoverableConsistencyErrors;
}
async function runChecks() {
let elapsedTimeMs;
await syncMutexService.doExclusively(async () => {
const startTime = new Date();
await runAllChecks();
2019-02-10 23:36:25 +08:00
elapsedTimeMs = Date.now() - startTime.getTime();
});
2019-02-02 16:26:57 +08:00
if (fixedIssues) {
messagingService.refreshTree();
2019-02-02 16:26:57 +08:00
}
if (unrecoverableConsistencyErrors) {
log.info(`Consistency checks failed (took ${elapsedTimeMs}ms)`);
2018-01-02 08:41:22 +08:00
messagingService.sendMessageToAllClients({type: 'consistency-checks-failed'});
}
else {
log.info(`All consistency checks passed (took ${elapsedTimeMs}ms)`);
}
2017-12-15 11:16:26 +08:00
}
2019-02-02 16:26:57 +08:00
function logFix(message) {
log.info("Consistency issue fixed: " + message);
}
function logError(message) {
log.info("Consistency error: " + message);
}
sqlInit.dbReady.then(() => {
setInterval(cls.wrap(runChecks), 60 * 60 * 1000);
2017-12-15 11:16:26 +08:00
// kickoff checks soon after startup (to not block the initial load)
2019-02-03 06:51:00 +08:00
setTimeout(cls.wrap(runChecks), 10 * 1000);
2017-12-15 11:16:26 +08:00
});
module.exports = {};