From 4e70ca6fd0232a0dc5104c1923a3a1f47b3a5c05 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Thu, 16 Sep 2021 17:36:06 +0100 Subject: [PATCH 01/15] Server: Exclude certain queries from slow log --- packages/server/src/db.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/server/src/db.ts b/packages/server/src/db.ts index 8fed626d97..f8ba558981 100644 --- a/packages/server/src/db.ts +++ b/packages/server/src/db.ts @@ -122,8 +122,16 @@ export function setupSlowQueryLog(connection: DbConnection, slowQueryLogMinDurat const queryInfos: Record = {}; + // These queries do not return a response, so "query-response" is not + // called. + const ignoredQueries = /^BEGIN|SAVEPOINT|RELEASE SAVEPOINT|COMMIT|ROLLBACK/gi; + connection.on('query', (data) => { - const timeoutId = makeSlowQueryHandler(slowQueryLogMinDuration, connection, data.sql, data.bindings); + const sql: string = data.sql; + + if (!sql || sql.match(ignoredQueries)) return; + + const timeoutId = makeSlowQueryHandler(slowQueryLogMinDuration, connection, sql, data.bindings); queryInfos[data.__knexQueryUid] = { timeoutId, From b56177a4e398332e5a55c4bdf7d86bdc3c7d6177 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Fri, 17 Sep 2021 10:59:10 +0100 Subject: [PATCH 02/15] Tools: Added tools to stress test Joplin Server --- .eslintignore | 6 + .gitignore | 6 + packages/app-cli/.gitignore | 4 +- packages/app-cli/app/cli-utils.js | 2 +- packages/app-cli/app/command-testing.ts | 95 +++++++++++++++ packages/app-cli/createUsers.sh | 52 +++++++++ packages/app-cli/package.json | 1 + packages/app-cli/tools/populateDatabase.ts | 86 ++++++++++++++ .../lib/services/debug/populateDatabase.ts | 59 ++++++---- packages/server/src/models/UserModel.ts | 8 ++ packages/server/src/routes/api/debug.ts | 15 ++- packages/server/src/tools/debugTools.ts | 109 ++++++++++++------ 12 files changed, 379 insertions(+), 64 deletions(-) create mode 100644 packages/app-cli/app/command-testing.ts create mode 100755 packages/app-cli/createUsers.sh create mode 100644 packages/app-cli/tools/populateDatabase.ts diff --git a/.eslintignore b/.eslintignore index ae5fa58570..4c2fe82b05 100644 --- a/.eslintignore +++ b/.eslintignore @@ -76,6 +76,9 @@ packages/app-cli/app/command-e2ee.js.map packages/app-cli/app/command-settingschema.d.ts packages/app-cli/app/command-settingschema.js packages/app-cli/app/command-settingschema.js.map +packages/app-cli/app/command-testing.d.ts +packages/app-cli/app/command-testing.js +packages/app-cli/app/command-testing.js.map packages/app-cli/app/services/plugins/PluginRunner.d.ts packages/app-cli/app/services/plugins/PluginRunner.js packages/app-cli/app/services/plugins/PluginRunner.js.map @@ -109,6 +112,9 @@ packages/app-cli/tests/services/plugins/sandboxProxy.js.map packages/app-cli/tests/testUtils.d.ts packages/app-cli/tests/testUtils.js packages/app-cli/tests/testUtils.js.map +packages/app-cli/tools/populateDatabase.d.ts +packages/app-cli/tools/populateDatabase.js +packages/app-cli/tools/populateDatabase.js.map packages/app-desktop/ElectronAppWrapper.d.ts packages/app-desktop/ElectronAppWrapper.js packages/app-desktop/ElectronAppWrapper.js.map diff --git a/.gitignore b/.gitignore index ce68bab617..fce6debad4 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,9 @@ packages/app-cli/app/command-e2ee.js.map packages/app-cli/app/command-settingschema.d.ts packages/app-cli/app/command-settingschema.js packages/app-cli/app/command-settingschema.js.map +packages/app-cli/app/command-testing.d.ts +packages/app-cli/app/command-testing.js +packages/app-cli/app/command-testing.js.map packages/app-cli/app/services/plugins/PluginRunner.d.ts packages/app-cli/app/services/plugins/PluginRunner.js packages/app-cli/app/services/plugins/PluginRunner.js.map @@ -94,6 +97,9 @@ packages/app-cli/tests/services/plugins/sandboxProxy.js.map packages/app-cli/tests/testUtils.d.ts packages/app-cli/tests/testUtils.js packages/app-cli/tests/testUtils.js.map +packages/app-cli/tools/populateDatabase.d.ts +packages/app-cli/tools/populateDatabase.js +packages/app-cli/tools/populateDatabase.js.map packages/app-desktop/ElectronAppWrapper.d.ts packages/app-desktop/ElectronAppWrapper.js packages/app-desktop/ElectronAppWrapper.js.map diff --git a/packages/app-cli/.gitignore b/packages/app-cli/.gitignore index 8651549eb3..48c78f239f 100644 --- a/packages/app-cli/.gitignore +++ b/packages/app-cli/.gitignore @@ -23,4 +23,6 @@ tests/support/dropbox-auth.txt tests/support/nextcloud-auth.json tests/support/onedrive-auth.txt build/ -patches/ \ No newline at end of file +patches/ +createUsers-*.txt +tools/temp/ diff --git a/packages/app-cli/app/cli-utils.js b/packages/app-cli/app/cli-utils.js index e19ca6d7a4..108e47bedd 100644 --- a/packages/app-cli/app/cli-utils.js +++ b/packages/app-cli/app/cli-utils.js @@ -89,7 +89,7 @@ cliUtils.makeCommandArgs = function(cmd, argv) { flags = cliUtils.parseFlags(flags); if (!flags.arg) { - booleanFlags.push(flags.short); + if (flags.short) booleanFlags.push(flags.short); if (flags.long) booleanFlags.push(flags.long); } diff --git a/packages/app-cli/app/command-testing.ts b/packages/app-cli/app/command-testing.ts new file mode 100644 index 0000000000..aa438ba770 --- /dev/null +++ b/packages/app-cli/app/command-testing.ts @@ -0,0 +1,95 @@ +const { BaseCommand } = require('./base-command.js'); +import { reg } from '@joplin/lib/registry'; +import Note from '@joplin/lib/models/Note'; +import uuid from '@joplin/lib/uuid'; +import populateDatabase from '@joplin/lib/services/debug/populateDatabase'; + +function randomElement(array: any[]): any { + if (!array.length) return null; + return array[Math.floor(Math.random() * array.length)]; +} + +function itemCount(args: any) { + const count = Number(args.arg0); + if (!count || isNaN(count)) throw new Error('Note count must be specified'); + return count; +} + +class Command extends BaseCommand { + usage() { + return 'testing [arg0]'; + } + + description() { + return 'testing'; + } + + enabled() { + return false; + } + + options(): any[] { + return [ + ['--folder-count ', 'Folders to create'], + ['--note-count ', 'Notes to create'], + ['--tag-count ', 'Tags to create'], + ['--tags-per-note ', 'Tags per note'], + ['--silent', 'Silent'], + ]; + } + + async action(args: any) { + const { command, options } = args; + + if (command === 'populate') { + await populateDatabase(reg.db(), { + folderCount: options['folder-count'], + noteCount: options['note-count'], + tagCount: options['tag-count'], + tagsPerNote: options['tags-per-note'], + silent: options['silent'], + }); + } + + const promises: any[] = []; + + if (command === 'createRandomNotes') { + const noteCount = itemCount(args); + + for (let i = 0; i < noteCount; i++) { + promises.push(Note.save({ + title: `Note ${uuid.createNano()}`, + })); + } + } + + if (command === 'updateRandomNotes') { + const noteCount = itemCount(args); + + const noteIds = await Note.allIds(); + + for (let i = 0; i < noteCount; i++) { + const noteId = randomElement(noteIds); + promises.push(Note.save({ + id: noteId, + title: `Note ${uuid.createNano()}`, + })); + } + } + + if (command === 'deleteRandomNotes') { + const noteCount = itemCount(args); + const noteIds = await Note.allIds(); + + for (let i = 0; i < noteCount; i++) { + const noteId = randomElement(noteIds); + promises.push(Note.delete(noteId)); + } + } + + await Promise.all(promises); + } + +} + +module.exports = Command; diff --git a/packages/app-cli/createUsers.sh b/packages/app-cli/createUsers.sh new file mode 100755 index 0000000000..0a7aa43432 --- /dev/null +++ b/packages/app-cli/createUsers.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +# Start the server with: +# +# JOPLIN_IS_TESTING=1 npm run start-dev + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + +# curl --data '{"action": "clearDatabase"}' -H 'Content-Type: application/json' http://api.joplincloud.local:22300/api/debug + +# SMALL + +# curl --data '{"action": "createTestUsers", "count": 400, "fromNum": 1}' -H 'Content-Type: application/json' http://api.joplincloud.local:22300/api/debug + +NUM=398 +while [ "$NUM" -lt 400 ]; do + NUM=$(( NUM + 1 )) + + echo "User $NUM" + + CMD_FILE="$SCRIPT_DIR/createUsers-$NUM.txt" + PROFILE_DIR=~/.config/joplindev-testing-$NUM + USER_EMAIL="user$NUM@example.com" + + rm -rf "$CMD_FILE" "$PROFILE_DIR" + touch "$CMD_FILE" + + FLAG_FOLDER_COUNT=100 + FLAG_NOTE_COUNT=1000 + FLAG_TAG_COUNT=20 + + if [ "$NUM" -gt 300 ]; then + FLAG_FOLDER_COUNT=2000 + FLAG_NOTE_COUNT=10000 + FLAG_TAG_COUNT=200 + fi + + if [ "$NUM" -gt 399 ]; then + FLAG_FOLDER_COUNT=10000 + FLAG_NOTE_COUNT=150000 + FLAG_TAG_COUNT=2000 + fi + + echo "testing populate --silent --folder-count $FLAG_FOLDER_COUNT --note-count $FLAG_NOTE_COUNT --tag-count $FLAG_TAG_COUNT" >> "$CMD_FILE" + echo "config keychain.supported 0" >> "$CMD_FILE" + echo "config sync.target 10" >> "$CMD_FILE" + echo "config sync.10.username $USER_EMAIL" >> "$CMD_FILE" + echo "config sync.10.password hunter1hunter2hunter3" >> "$CMD_FILE" + echo "sync" >> "$CMD_FILE" + + npm start -- --profile "$PROFILE_DIR" batch "$CMD_FILE" +done diff --git a/packages/app-cli/package.json b/packages/app-cli/package.json index 850c09c85f..cd20a23af7 100644 --- a/packages/app-cli/package.json +++ b/packages/app-cli/package.json @@ -10,6 +10,7 @@ "test-ci": "jest --config=jest.config.js --forceExit", "build": "gulp build", "start": "gulp build -L && node \"build/main.js\" --stack-trace-enabled --log-level debug --env dev", + "start-no-build": "node \"build/main.js\" --stack-trace-enabled --log-level debug --env dev", "tsc": "node node_modules/typescript/bin/tsc --project tsconfig.json", "watch": "node node_modules/typescript/bin/tsc --watch --project tsconfig.json" }, diff --git a/packages/app-cli/tools/populateDatabase.ts b/packages/app-cli/tools/populateDatabase.ts new file mode 100644 index 0000000000..c073e8d495 --- /dev/null +++ b/packages/app-cli/tools/populateDatabase.ts @@ -0,0 +1,86 @@ +import * as fs from 'fs-extra'; +import { homedir } from 'os'; +import { execCommand2 } from '@joplin/tools/tool-utils'; +import { chdir } from 'process'; + +const minUserNum = 1; +const maxUserNum = 400; + +const cliDir = `${__dirname}/..`; +const tempDir = `${__dirname}/temp`; + +function randomInt(min: number, max: number) { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +const processing_: Record = {}; + +const processUser = async (userNum: number) => { + if (processing_[userNum]) { + console.info(`User already being processed: ${userNum} - skipping`); + return; + } + + processing_[userNum] = true; + + try { + const userEmail = `user${userNum}@example.com`; + const userPassword = 'hunter1hunter2hunter3'; + const commandFile = `${tempDir}/populateDatabase-${userNum}.txt`; + const profileDir = `${homedir()}/.config/joplindev-populate/joplindev-testing-${userNum}`; + + const commands: string[] = []; + const jackpot = Math.random() >= 0.95 ? 100 : 1; + + commands.push(`testing createRandomNotes ${randomInt(1, 500 * jackpot)}`); + commands.push(`testing updateRandomNotes ${randomInt(1, 1500 * jackpot)}`); + commands.push(`testing deleteRandomNotes ${randomInt(1, 200 * jackpot)}`); + commands.push('config keychain.supported 0'); + commands.push('config sync.target 10'); + commands.push(`config sync.10.username ${userEmail}`); + commands.push(`config sync.10.password ${userPassword}`); + commands.push('sync'); + + await fs.writeFile(commandFile, commands.join('\n'), 'utf8'); + + await chdir(cliDir); + + await execCommand2(['npm', 'run', 'start-no-build', '--', '--profile', profileDir, 'batch', commandFile]); + } catch (error) { + console.error(`Could not process user ${userNum}:`, error); + } finally { + delete processing_[userNum]; + } +}; + +const waitForProcessing = (count: number) => { + return new Promise((resolve) => { + const iid = setInterval(() => { + if (Object.keys(processing_).length <= count) { + clearInterval(iid); + resolve(null); + } + }, 100); + }); +}; + +const main = async () => { + await fs.mkdirp(tempDir); + + // Build the app once before starting, because we'll use start-no-build to + // run the scripts (faster) + await execCommand2(['npm', 'run', 'build']); + + while (true) { + const userNum = randomInt(minUserNum, maxUserNum); + void processUser(userNum); + await waitForProcessing(10); + } +}; + +main().catch((error) => { + console.error('Fatal error', error); + process.exit(1); +}); diff --git a/packages/lib/services/debug/populateDatabase.ts b/packages/lib/services/debug/populateDatabase.ts index c666328a62..f9d85ed874 100644 --- a/packages/lib/services/debug/populateDatabase.ts +++ b/packages/lib/services/debug/populateDatabase.ts @@ -2,6 +2,15 @@ import Folder from '../../models/Folder'; import Note from '../../models/Note'; import Tag from '../../models/Tag'; +export interface Options { + folderCount?: number; + noteCount?: number; + tagCount?: number; + tagsPerNote?: number; + silent?: number; + clearDatabase?: boolean; +} + function randomIndex(array: any[]): number { return Math.round(Math.random() * (array.length - 1)); } @@ -31,19 +40,23 @@ function randomElement(array: any[]): any { // Use the constants below to define how many folders, notes and tags // should be created. -export default async function populateDatabase(db: any) { - await db.clearForTesting(); +export default async function populateDatabase(db: any, options: Options = null) { + options = { + folderCount: 0, + noteCount: 0, + tagCount: 0, + tagsPerNote: 0, + clearDatabase: false, + ...options, + }; - const folderCount = 20; - const noteCount = 200; - const tagCount = 2000; - const tagsPerNote = 3; + if (options.clearDatabase) await db.clearForTesting(); const createdFolderIds: string[] = []; const createdNoteIds: string[] = []; const createdTagIds: string[] = []; - for (let i = 0; i < folderCount; i++) { + for (let i = 0; i < options.folderCount; i++) { const folder: any = { title: `folder${i}`, }; @@ -58,15 +71,15 @@ export default async function populateDatabase(db: any) { const savedFolder = await Folder.save(folder); createdFolderIds.push(savedFolder.id); - console.info(`Folders: ${i} / ${folderCount}`); + if (!options.silent) console.info(`Folders: ${i} / ${options.folderCount}`); } let tagBatch = []; - for (let i = 0; i < tagCount; i++) { + for (let i = 0; i < options.tagCount; i++) { const tagTitle = randomElement(wordList); // `tag${i}` tagBatch.push(Tag.save({ title: tagTitle }, { dispatchUpdateAction: false }).then((savedTag: any) => { createdTagIds.push(savedTag.id); - console.info(`Tags: ${i} / ${tagCount}`); + if (!options.silent) console.info(`Tags: ${i} / ${options.tagCount}`); })); if (tagBatch.length > 1000) { @@ -81,14 +94,14 @@ export default async function populateDatabase(db: any) { } let noteBatch = []; - for (let i = 0; i < noteCount; i++) { + for (let i = 0; i < options.noteCount; i++) { const note: any = { title: `note${i}`, body: `This is note num. ${i}` }; const parentIndex = randomIndex(createdFolderIds); note.parent_id = createdFolderIds[parentIndex]; noteBatch.push(Note.save(note, { dispatchUpdateAction: false }).then((savedNote: any) => { createdNoteIds.push(savedNote.id); - console.info(`Notes: ${i} / ${noteCount}`); + console.info(`Notes: ${i} / ${options.noteCount}`); })); if (noteBatch.length > 1000) { @@ -102,21 +115,23 @@ export default async function populateDatabase(db: any) { noteBatch = []; } - let noteTagBatch = []; - for (const noteId of createdNoteIds) { - const tagIds = randomElements(createdTagIds, tagsPerNote); - noteTagBatch.push(Tag.setNoteTagsByIds(noteId, tagIds)); + if (options.tagsPerNote) { + let noteTagBatch = []; + for (const noteId of createdNoteIds) { + const tagIds = randomElements(createdTagIds, options.tagsPerNote); + noteTagBatch.push(Tag.setNoteTagsByIds(noteId, tagIds)); - if (noteTagBatch.length > 1000) { + if (noteTagBatch.length > 1000) { + await Promise.all(noteTagBatch); + noteTagBatch = []; + } + } + + if (noteTagBatch.length) { await Promise.all(noteTagBatch); noteTagBatch = []; } } - - if (noteTagBatch.length) { - await Promise.all(noteTagBatch); - noteTagBatch = []; - } } const wordList = ['a', 'ability', 'able', 'about', 'above', 'accept', 'according', 'account', 'across', 'act', 'action', 'activity', 'actually', 'add', 'address', 'administration', 'admit', 'adult', 'affect', 'after', 'again', 'against', 'age', 'agency', 'agent', 'ago', 'agree', 'agreement', 'ahead', 'air', 'all', 'allow', 'almost', 'alone', 'along', 'already', 'also', 'although', 'always', 'American', 'among', 'amount', 'analysis', 'and', 'animal', 'another', 'answer', 'any', 'anyone', 'anything', 'appear', 'apply', 'approach', 'area', 'argue', 'arm', 'around', 'arrive', 'art', 'article', 'artist', 'as', 'ask', 'assume', 'at', 'attack', 'attention', 'attorney', 'audience', 'author', 'authority', 'available', 'avoid', 'away', 'baby', 'back', 'bad', 'bag', 'ball', 'bank', 'bar', 'base', 'be', 'beat', 'beautiful', 'because', 'become', 'bed', 'before', 'begin', 'behavior', 'behind', 'believe', 'benefit', 'best', 'better', 'between', 'beyond', 'big', 'bill', 'billion', 'bit', 'black', 'blood', 'blue', 'board', 'body', 'book', 'born', 'both', 'box', 'boy', 'break', 'bring', 'brother', 'budget', 'build', 'building', 'business', 'but', 'buy', 'by', 'call', 'camera', 'campaign', 'can', 'cancer', 'candidate', 'capital', 'car', 'card', 'care', 'career', 'carry', 'case', 'catch', 'cause', 'cell', 'center', 'central', 'century', 'certain', 'certainly', 'chair', 'challenge', 'chance', 'change', 'character', 'charge', 'check', 'child', 'choice', 'choose', 'church', 'citizen', 'city', 'civil', 'claim', 'class', 'clear', 'clearly', 'close', 'coach', 'cold', 'collection', 'college', 'color', 'come', 'commercial', 'common', 'community', 'company', 'compare', 'computer', 'concern', 'condition', 'conference', 'Congress', 'consider', 'consumer', 'contain', 'continue', 'control', 'cost', 'could', 'country', 'couple', 'course', 'court', 'cover', 'create', 'crime', 'cultural', 'culture', 'cup', 'current', 'customer', 'cut', 'dark', 'data', 'daughter', 'day', 'dead', 'deal', 'death', 'debate', 'decade', 'decide', 'decision', 'deep', 'defense', 'degree', 'Democrat', 'democratic', 'describe', 'design', 'despite', 'detail', 'determine', 'develop', 'development', 'die', 'difference', 'different', 'difficult', 'dinner', 'direction', 'director', 'discover', 'discuss', 'discussion', 'disease', 'do', 'doctor', 'dog', 'door', 'down', 'draw', 'dream', 'drive', 'drop', 'drug', 'during', 'each', 'early', 'east', 'easy', 'eat', 'economic', 'economy', 'edge', 'education', 'effect', 'effort', 'eight', 'either', 'election', 'else', 'employee', 'end', 'energy', 'enjoy', 'enough', 'enter', 'entire', 'environment', 'environmental', 'especially', 'establish', 'even', 'evening', 'event', 'ever', 'every', 'everybody', 'everyone', 'everything', 'evidence', 'exactly', 'example', 'executive', 'exist', 'expect', 'experience', 'expert', 'explain', 'eye', 'face', 'fact', 'factor', 'fail', 'fall', 'family', 'far', 'fast', 'father', 'fear', 'federal', 'feel', 'feeling', 'few', 'field', 'fight', 'figure', 'fill', 'film', 'final', 'finally', 'financial', 'find', 'fine', 'finger', 'finish', 'fire', 'firm', 'first', 'fish', 'five', 'floor', 'fly', 'focus', 'follow', 'food', 'foot', 'for', 'force', 'foreign', 'forget', 'form', 'former', 'forward', 'four', 'free', 'friend', 'from', 'front', 'full', 'fund', 'future', 'game', 'garden', 'gas', 'general', 'generation', 'get', 'girl', 'give', 'glass', 'go', 'goal', 'good', 'government', 'great', 'green', 'ground', 'group', 'grow', 'growth', 'guess', 'gun', 'guy', 'hair', 'half', 'hand', 'hang', 'happen', 'happy', 'hard', 'have', 'he', 'head', 'health', 'hear', 'heart', 'heat', 'heavy', 'help', 'her', 'here', 'herself', 'high', 'him', 'himself', 'his', 'history', 'hit', 'hold', 'home', 'hope', 'hospital', 'hot', 'hotel', 'hour', 'house', 'how', 'however', 'huge', 'human', 'hundred', 'husband', 'I', 'idea', 'identify', 'if', 'image', 'imagine', 'impact', 'important', 'improve', 'in', 'include', 'including', 'increase', 'indeed', 'indicate', 'individual', 'industry', 'information', 'inside', 'instead', 'institution', 'interest', 'interesting', 'international', 'interview', 'into', 'investment', 'involve', 'issue', 'it', 'item', 'its', 'itself', 'job', 'join', 'just', 'keep', 'key', 'kid', 'kill', 'kind', 'kitchen', 'know', 'knowledge', 'land', 'language', 'large', 'last', 'late', 'later', 'laugh', 'law', 'lawyer', 'lay', 'lead', 'leader', 'learn', 'least', 'leave', 'left', 'leg', 'legal', 'less', 'let', 'letter', 'level', 'lie', 'life', 'light', 'like', 'likely', 'line', 'list', 'listen', 'little', 'live', 'local', 'long', 'look', 'lose', 'loss', 'lot', 'love', 'low', 'machine', 'magazine', 'main', 'maintain', 'major', 'majority', 'make', 'man', 'manage', 'management', 'manager', 'many', 'market', 'marriage', 'material', 'matter', 'may', 'maybe', 'me', 'mean', 'measure', 'media', 'medical', 'meet', 'meeting', 'member', 'memory', 'mention', 'message', 'method', 'middle', 'might', 'military', 'million', 'mind', 'minute', 'miss', 'mission', 'model', 'modern', 'moment', 'money', 'month', 'more', 'morning', 'most', 'mother', 'mouth', 'move', 'movement', 'movie', 'Mr', 'Mrs', 'much', 'music', 'must', 'my', 'myself', 'name', 'nation', 'national', 'natural', 'nature', 'near', 'nearly', 'necessary', 'need', 'network', 'never', 'new', 'news', 'newspaper', 'next', 'nice', 'night', 'no', 'none', 'nor', 'north', 'not', 'note', 'nothing', 'notice', 'now', 'n\'t', 'number', 'occur', 'of', 'off', 'offer', 'office', 'officer', 'official', 'often', 'oh', 'oil', 'ok', 'old', 'on', 'once', 'one', 'only', 'onto', 'open', 'operation', 'opportunity', 'option', 'or', 'order', 'organization', 'other', 'others', 'our', 'out', 'outside', 'over', 'own', 'owner', 'page', 'pain', 'painting', 'paper', 'parent', 'part', 'participant', 'particular', 'particularly', 'partner', 'party', 'pass', 'past', 'patient', 'pattern', 'pay', 'peace', 'people', 'per', 'perform', 'performance', 'perhaps', 'period', 'person', 'personal', 'phone', 'physical', 'pick', 'picture', 'piece', 'place', 'plan', 'plant', 'play', 'player', 'PM', 'point', 'police', 'policy', 'political', 'politics', 'poor', 'popular', 'population', 'position', 'positive', 'possible', 'power', 'practice', 'prepare', 'present', 'president', 'pressure', 'pretty', 'prevent', 'price', 'private', 'probably', 'problem', 'process', 'produce', 'product', 'production', 'professional', 'professor', 'program', 'project', 'property', 'protect', 'prove', 'provide', 'public', 'pull', 'purpose', 'push', 'put', 'quality', 'question', 'quickly', 'quite', 'race', 'radio', 'raise', 'range', 'rate', 'rather', 'reach', 'read', 'ready', 'real', 'reality', 'realize', 'really', 'reason', 'receive', 'recent', 'recently', 'recognize', 'record', 'red', 'reduce', 'reflect', 'region', 'relate', 'relationship', 'religious', 'remain', 'remember', 'remove', 'report', 'represent', 'Republican', 'require', 'research', 'resource', 'respond', 'response', 'responsibility', 'rest', 'result', 'return', 'reveal', 'rich', 'right', 'rise', 'risk', 'road', 'rock', 'role', 'room', 'rule', 'run', 'safe', 'same', 'save', 'say', 'scene', 'school', 'science', 'scientist', 'score', 'sea', 'season', 'seat', 'second', 'section', 'security', 'see', 'seek', 'seem', 'sell', 'send', 'senior', 'sense', 'series', 'serious', 'serve', 'service', 'set', 'seven', 'several', 'sex', 'sexual', 'shake', 'share', 'she', 'shoot', 'short', 'shot', 'should', 'shoulder', 'show', 'side', 'sign', 'significant', 'similar', 'simple', 'simply', 'since', 'sing', 'single', 'sister', 'sit', 'site', 'situation', 'six', 'size', 'skill', 'skin', 'small', 'smile', 'so', 'social', 'society', 'soldier', 'some', 'somebody', 'someone', 'something', 'sometimes', 'son', 'song', 'soon', 'sort', 'sound', 'source', 'south', 'southern', 'space', 'speak', 'special', 'specific', 'speech', 'spend', 'sport', 'spring', 'staff', 'stage', 'stand', 'standard', 'star', 'start', 'state', 'statement', 'station', 'stay', 'step', 'still', 'stock', 'stop', 'store', 'story', 'strategy', 'street', 'strong', 'structure', 'student', 'study', 'stuff', 'style', 'subject', 'success', 'successful', 'such', 'suddenly', 'suffer', 'suggest', 'summer', 'support', 'sure', 'surface', 'system', 'table', 'take', 'talk', 'task', 'tax', 'teach', 'teacher', 'team', 'technology', 'television', 'tell', 'ten', 'tend', 'term', 'test', 'than', 'thank', 'that', 'the', 'their', 'them', 'themselves', 'then', 'theory', 'there', 'these', 'they', 'thing', 'think', 'third', 'this', 'those', 'though', 'thought', 'thousand', 'threat', 'three', 'through', 'throughout', 'throw', 'thus', 'time', 'to', 'today', 'together', 'tonight', 'too', 'top', 'total', 'tough', 'toward', 'town', 'trade', 'traditional', 'training', 'travel', 'treat', 'treatment', 'tree', 'trial', 'trip', 'trouble', 'true', 'truth', 'try', 'turn', 'TV', 'two', 'type', 'under', 'understand', 'unit', 'until', 'up', 'upon', 'us', 'use', 'usually', 'value', 'various', 'very', 'victim', 'view', 'violence', 'visit', 'voice', 'vote', 'wait', 'walk', 'wall', 'want', 'war', 'watch', 'water', 'way', 'we', 'weapon', 'wear', 'week', 'weight', 'well', 'west', 'western', 'what', 'whatever', 'when', 'where', 'whether', 'which', 'while', 'white', 'who', 'whole', 'whom', 'whose', 'why', 'wide', 'wife', 'will', 'win', 'wind', 'window', 'wish', 'with', 'within', 'without', 'woman', 'wonder', 'word', 'work', 'worker', 'world', 'worry', 'would', 'write', 'writer', 'wrong', 'yard', 'yeah', 'year', 'yes', 'yet', 'you', 'young', 'your', 'yourself']; diff --git a/packages/server/src/models/UserModel.ts b/packages/server/src/models/UserModel.ts index fcc8d0cab2..921ed429d3 100644 --- a/packages/server/src/models/UserModel.ts +++ b/packages/server/src/models/UserModel.ts @@ -469,4 +469,12 @@ export default class UserModel extends BaseModel { }, 'UserModel::save'); } + public async saveMulti(users: User[], options: SaveOptions = {}): Promise { + await this.withTransaction(async () => { + for (const user of users) { + await this.save(user, options); + } + }, 'UserModel::saveMulti'); + } + } diff --git a/packages/server/src/routes/api/debug.ts b/packages/server/src/routes/api/debug.ts index 8e7b7363ea..3b4282a24a 100644 --- a/packages/server/src/routes/api/debug.ts +++ b/packages/server/src/routes/api/debug.ts @@ -1,5 +1,5 @@ import config from '../../config'; -import { createTestUsers } from '../../tools/debugTools'; +import { clearDatabase, createTestUsers, CreateTestUsersOptions } from '../../tools/debugTools'; import { bodyFields } from '../../utils/requestUtils'; import Router from '../../utils/Router'; import { RouteType } from '../../utils/types'; @@ -12,6 +12,8 @@ router.public = true; interface Query { action: string; + count?: number; + fromNum?: number; } router.post('api/debug', async (_path: SubPath, ctx: AppContext) => { @@ -20,7 +22,16 @@ router.post('api/debug', async (_path: SubPath, ctx: AppContext) => { console.info(`Action: ${query.action}`); if (query.action === 'createTestUsers') { - await createTestUsers(ctx.joplin.db, config()); + const options: CreateTestUsersOptions = {}; + + if ('count' in query) options.count = query.count; + if ('fromNum' in query) options.fromNum = query.fromNum; + + await createTestUsers(ctx.joplin.db, config(), options); + } + + if (query.action === 'clearDatabase') { + await clearDatabase(ctx.joplin.db); } }); diff --git a/packages/server/src/tools/debugTools.ts b/packages/server/src/tools/debugTools.ts index d30fec16fb..b841825479 100644 --- a/packages/server/src/tools/debugTools.ts +++ b/packages/server/src/tools/debugTools.ts @@ -1,9 +1,14 @@ import { DbConnection, dropTables, migrateLatest } from '../db'; import newModelFactory from '../models/factory'; import { AccountType } from '../models/UserModel'; -import { UserFlagType } from '../services/database/types'; +import { User, UserFlagType } from '../services/database/types'; import { Config } from '../utils/types'; +export interface CreateTestUsersOptions { + count?: number; + fromNum?: number; +} + export async function handleDebugCommands(argv: any, db: DbConnection, config: Config): Promise { if (argv.debugCreateTestUsers) { await createTestUsers(db, config); @@ -14,51 +19,79 @@ export async function handleDebugCommands(argv: any, db: DbConnection, config: C return true; } -export async function createTestUsers(db: DbConnection, config: Config) { +export async function clearDatabase(db: DbConnection) { await dropTables(db); await migrateLatest(db); +} + +export async function createTestUsers(db: DbConnection, config: Config, options: CreateTestUsersOptions = null) { + options = { + count: 0, + fromNum: 1, + ...options, + }; const password = 'hunter1hunter2hunter3'; - const models = newModelFactory(db, config); - for (let userNum = 1; userNum <= 2; userNum++) { - await models.user().save({ - email: `user${userNum}@example.com`, - password, - full_name: `User ${userNum}`, - }); - } + if (options.count) { + const models = newModelFactory(db, config); - { - const { user } = await models.subscription().saveUserAndSubscription( - 'usersub@example.com', - 'With Sub', - AccountType.Basic, - 'usr_111', - 'sub_111' - ); - await models.user().save({ id: user.id, password }); - } + const users: User[] = []; - { - const { user, subscription } = await models.subscription().saveUserAndSubscription( - 'userfailedpayment@example.com', - 'Failed Payment', - AccountType.Basic, - 'usr_222', - 'sub_222' - ); - await models.user().save({ id: user.id, password }); - await models.subscription().handlePayment(subscription.stripe_subscription_id, false); - } + for (let i = 0; i < options.count; i++) { + const userNum = i + options.fromNum; + users.push({ + email: `user${userNum}@example.com`, + password, + full_name: `User ${userNum}`, + }); + } - { - const user = await models.user().save({ - email: 'userwithflags@example.com', - password, - full_name: 'User Withflags', - }); + await models.user().saveMulti(users); + } else { + await dropTables(db); + await migrateLatest(db); + const models = newModelFactory(db, config); - await models.userFlag().add(user.id, UserFlagType.AccountOverLimit); + for (let userNum = 1; userNum <= 2; userNum++) { + await models.user().save({ + email: `user${userNum}@example.com`, + password, + full_name: `User ${userNum}`, + }); + } + + { + const { user } = await models.subscription().saveUserAndSubscription( + 'usersub@example.com', + 'With Sub', + AccountType.Basic, + 'usr_111', + 'sub_111' + ); + await models.user().save({ id: user.id, password }); + } + + { + const { user, subscription } = await models.subscription().saveUserAndSubscription( + 'userfailedpayment@example.com', + 'Failed Payment', + AccountType.Basic, + 'usr_222', + 'sub_222' + ); + await models.user().save({ id: user.id, password }); + await models.subscription().handlePayment(subscription.stripe_subscription_id, false); + } + + { + const user = await models.user().save({ + email: 'userwithflags@example.com', + password, + full_name: 'User Withflags', + }); + + await models.userFlag().add(user.id, UserFlagType.AccountOverLimit); + } } } From f91b4edb30d45b92474bd87f4a177d32e0ca9da1 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Fri, 17 Sep 2021 18:27:25 +0100 Subject: [PATCH 03/15] Tools: Tweak to stress test script --- packages/app-cli/tools/populateDatabase.ts | 24 +++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/app-cli/tools/populateDatabase.ts b/packages/app-cli/tools/populateDatabase.ts index c073e8d495..b791b9cbb4 100644 --- a/packages/app-cli/tools/populateDatabase.ts +++ b/packages/app-cli/tools/populateDatabase.ts @@ -1,3 +1,20 @@ +// This script can be used to simulate a running production environment, by +// having multiple users in parallel changing notes and synchronising. +// +// To get it working: +// +// - Run the Postgres database -- `sudo docker-compose --file docker-compose.db-dev.yml up` +// - Update the DB parameters in ~/joplin-credentials/server.env to use the dev +// database +// - Run the server - `JOPLIN_IS_TESTING=1 npm run start-dev` +// - Then run this script - `node populateDatabase.js` +// +// Currently it doesn't actually create the users, so that should be done using: +// +// curl --data '{"action": "createTestUsers", "count": 400, "fromNum": 1}' -H 'Content-Type: application/json' http://api.joplincloud.local:22300/api/debug +// +// That will create n users with email `user@example.com` + import * as fs from 'fs-extra'; import { homedir } from 'os'; import { execCommand2 } from '@joplin/tools/tool-utils'; @@ -73,8 +90,13 @@ const main = async () => { // run the scripts (faster) await execCommand2(['npm', 'run', 'build']); + const focusUserNum = 400; + while (true) { - const userNum = randomInt(minUserNum, maxUserNum); + let userNum = randomInt(minUserNum, maxUserNum); + + if (Math.random() >= .7) userNum = focusUserNum; + void processUser(userNum); await waitForProcessing(10); } From cd877f64cd0dc404524c06bc1c89a931922d2663 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Sat, 18 Sep 2021 11:29:24 +0100 Subject: [PATCH 04/15] Server: Improved support for background tasks and added admin UI to view them --- .../src/middleware/notificationHandler.ts | 9 +- .../server/src/models/NotificationModel.ts | 10 +- .../server/src/models/SubscriptionModel.ts | 2 +- packages/server/src/routes/index/tasks.ts | 135 ++++++++++++++++++ packages/server/src/routes/routes.ts | 18 +-- packages/server/src/services/CronService.ts | 42 ------ .../server/src/services/TaskService.test.ts | 82 +++++++++++ packages/server/src/services/TaskService.ts | 108 ++++++++++++++ .../server/src/services/database/types.ts | 1 + packages/server/src/services/types.ts | 4 +- packages/server/src/utils/routeUtils.ts | 1 + packages/server/src/utils/setupAppContext.ts | 4 +- packages/server/src/utils/setupTaskService.ts | 49 +++++++ packages/server/src/utils/startServices.ts | 2 +- packages/server/src/utils/time.ts | 3 +- packages/server/src/utils/types.ts | 2 +- packages/server/src/utils/views/table.ts | 27 ++-- .../server/src/views/index/tasks.mustache | 11 ++ .../server/src/views/partials/navbar.mustache | 3 + .../src/views/partials/notifications.mustache | 2 +- .../server/src/views/partials/table.mustache | 34 ++--- .../src/views/partials/tableHeader.mustache | 7 +- .../src/views/partials/tableRowItem.mustache | 7 +- 23 files changed, 476 insertions(+), 87 deletions(-) create mode 100644 packages/server/src/routes/index/tasks.ts delete mode 100644 packages/server/src/services/CronService.ts create mode 100644 packages/server/src/services/TaskService.test.ts create mode 100644 packages/server/src/services/TaskService.ts create mode 100644 packages/server/src/utils/setupTaskService.ts create mode 100644 packages/server/src/views/index/tasks.mustache diff --git a/packages/server/src/middleware/notificationHandler.ts b/packages/server/src/middleware/notificationHandler.ts index 90a784d4bc..1ba22ba56e 100644 --- a/packages/server/src/middleware/notificationHandler.ts +++ b/packages/server/src/middleware/notificationHandler.ts @@ -42,6 +42,13 @@ async function handleSqliteInProdNotification(ctx: AppContext) { } } +function levelClassName(level: NotificationLevel): string { + if (level === NotificationLevel.Important) return 'is-warning'; + if (level === NotificationLevel.Normal) return 'is-info'; + if (level === NotificationLevel.Error) return 'is-danger'; + throw new Error(`Unknown level: ${level}`); +} + async function makeNotificationViews(ctx: AppContext): Promise { const markdownIt = new MarkdownIt(); @@ -52,7 +59,7 @@ async function makeNotificationViews(ctx: AppContext): Promise { level: NotificationLevel.Normal, message: 'Thank you! Your account has been successfully upgraded to Pro.', }, + [NotificationKey.Any]: { + level: NotificationLevel.Normal, + message: '', + }, }; const type = notificationTypes[key]; @@ -72,7 +78,9 @@ export default class NotificationModel extends BaseModel { } } - return this.save({ key, message, level, owner_id: userId }); + const actualKey = key === NotificationKey.Any ? `any_${uuidgen()}` : key; + + return this.save({ key: actualKey, message, level, owner_id: userId }); } public async markAsRead(userId: Uuid, key: NotificationKey): Promise { diff --git a/packages/server/src/models/SubscriptionModel.ts b/packages/server/src/models/SubscriptionModel.ts index d29eaeaa27..0002b02526 100644 --- a/packages/server/src/models/SubscriptionModel.ts +++ b/packages/server/src/models/SubscriptionModel.ts @@ -98,7 +98,7 @@ export default class SubscriptionModel extends BaseModel { // failed. // // We don't update the user can_upload and enabled properties here - // because it's done after a few days from CronService. + // because it's done after a few days from TaskService. if (!sub.last_payment_failed_time) { const user = await this.models().user().load(sub.user_id, { fields: ['email', 'id', 'full_name'] }); diff --git a/packages/server/src/routes/index/tasks.ts b/packages/server/src/routes/index/tasks.ts new file mode 100644 index 0000000000..c4e75292a3 --- /dev/null +++ b/packages/server/src/routes/index/tasks.ts @@ -0,0 +1,135 @@ +import { makeUrl, redirect, SubPath, UrlType } from '../../utils/routeUtils'; +import Router from '../../utils/Router'; +import { RouteType } from '../../utils/types'; +import { AppContext } from '../../utils/types'; +import { bodyFields } from '../../utils/requestUtils'; +import { ErrorBadRequest, ErrorForbidden } from '../../utils/errors'; +import defaultView from '../../utils/defaultView'; +import { makeTableView, Row, Table } from '../../utils/views/table'; +import { yesOrNo } from '../../utils/strings'; +import { formatDateTime } from '../../utils/time'; +import { createCsrfTag } from '../../utils/csrf'; +import { RunType } from '../../services/TaskService'; +import { NotificationKey } from '../../models/NotificationModel'; +import { NotificationLevel } from '../../services/database/types'; + +const router: Router = new Router(RouteType.Web); + +router.post('tasks', async (_path: SubPath, ctx: AppContext) => { + const user = ctx.joplin.owner; + if (!user.is_admin) throw new ErrorForbidden(); + + const taskService = ctx.joplin.services.tasks; + const fields: any = await bodyFields(ctx.req); + + if (fields.startTaskButton) { + const errors: Error[] = []; + + for (const k of Object.keys(fields)) { + if (k.startsWith('checkbox_')) { + const taskId = k.substr(9); + try { + void taskService.runTask(taskId, RunType.Manual); + } catch (error) { + errors.push(error); + } + } + } + + if (errors.length) { + await ctx.joplin.models.notification().add( + user.id, + NotificationKey.Any, + NotificationLevel.Error, + `Some tasks could not be started: ${errors.join('. ')}` + ); + } + } else { + throw new ErrorBadRequest('Invalid action'); + } + + return redirect(ctx, makeUrl(UrlType.Tasks)); +}); + +router.get('tasks', async (_path: SubPath, ctx: AppContext) => { + const user = ctx.joplin.owner; + if (!user.is_admin) throw new ErrorForbidden(); + + const taskService = ctx.joplin.services.tasks; + + const taskRows: Row[] = []; + for (const [taskId, task] of Object.entries(taskService.tasks)) { + const state = taskService.taskState(taskId); + + taskRows.push([ + { + value: `checkbox_${taskId}`, + checkbox: true, + }, + { + value: taskId, + }, + { + value: task.description, + }, + { + value: task.schedule, + }, + { + value: yesOrNo(state.running), + }, + { + value: state.lastRunTime ? formatDateTime(state.lastRunTime) : '-', + }, + { + value: state.lastCompletionTime ? formatDateTime(state.lastCompletionTime) : '-', + }, + ]); + } + + const table: Table = { + headers: [ + { + name: 'select', + label: '', + }, + { + name: 'id', + label: 'ID', + }, + { + name: 'description', + label: 'Description', + }, + { + name: 'schedule', + label: 'Schedule', + }, + { + name: 'running', + label: 'Running', + }, + { + name: 'lastRunTime', + label: 'Last Run', + }, + { + name: 'lastCompletionTime', + label: 'Last Completion', + }, + ], + rows: taskRows, + }; + + return { + ...defaultView('tasks', 'Tasks'), + content: { + itemTable: makeTableView(table), + postUrl: makeUrl(UrlType.Tasks), + csrfTag: await createCsrfTag(ctx), + }, + cssFiles: ['index/tasks'], + }; +}); + +export default router; diff --git a/packages/server/src/routes/routes.ts b/packages/server/src/routes/routes.ts index 745f60f60a..9246943a6c 100644 --- a/packages/server/src/routes/routes.ts +++ b/packages/server/src/routes/routes.ts @@ -1,31 +1,32 @@ import { Routers } from '../utils/routeUtils'; import apiBatch from './api/batch'; +import apiBatchItems from './api/batch_items'; import apiDebug from './api/debug'; import apiEvents from './api/events'; -import apiBatchItems from './api/batch_items'; import apiItems from './api/items'; import apiPing from './api/ping'; import apiSessions from './api/sessions'; -import apiUsers from './api/users'; import apiShares from './api/shares'; import apiShareUsers from './api/share_users'; +import apiUsers from './api/users'; import indexChanges from './index/changes'; +import indexHelp from './index/help'; import indexHome from './index/home'; import indexItems from './index/items'; import indexLogin from './index/login'; import indexLogout from './index/logout'; import indexNotifications from './index/notifications'; import indexPassword from './index/password'; -import indexSignup from './index/signup'; -import indexShares from './index/shares'; -import indexUsers from './index/users'; -import indexStripe from './index/stripe'; -import indexTerms from './index/terms'; import indexPrivacy from './index/privacy'; +import indexShares from './index/shares'; +import indexSignup from './index/signup'; +import indexStripe from './index/stripe'; +import indexTasks from './index/tasks'; +import indexTerms from './index/terms'; import indexUpgrade from './index/upgrade'; -import indexHelp from './index/help'; +import indexUsers from './index/users'; import defaultRoute from './default'; @@ -56,6 +57,7 @@ const routes: Routers = { 'privacy': indexPrivacy, 'upgrade': indexUpgrade, 'help': indexHelp, + 'tasks': indexTasks, '': defaultRoute, }; diff --git a/packages/server/src/services/CronService.ts b/packages/server/src/services/CronService.ts deleted file mode 100644 index 98f2d382fd..0000000000 --- a/packages/server/src/services/CronService.ts +++ /dev/null @@ -1,42 +0,0 @@ -import Logger from '@joplin/lib/Logger'; -import BaseService from './BaseService'; -const cron = require('node-cron'); - -const logger = Logger.create('cron'); - -async function runCronTask(name: string, callback: Function) { - const startTime = Date.now(); - logger.info(`Running task "${name}"`); - try { - await callback(); - } catch (error) { - logger.error(`On task "${name}"`, error); - } - logger.info(`Completed task "${name}" in ${Date.now() - startTime}ms`); -} - -export default class CronService extends BaseService { - - public async runInBackground() { - cron.schedule('0 */6 * * *', async () => { - await runCronTask('deleteExpiredTokens', async () => this.models.token().deleteExpiredTokens()); - }); - - cron.schedule('0 * * * *', async () => { - await runCronTask('updateTotalSizes', async () => this.models.item().updateTotalSizes()); - }); - - cron.schedule('0 12 * * *', async () => { - await runCronTask('handleBetaUserEmails', async () => this.models.user().handleBetaUserEmails()); - }); - - cron.schedule('0 13 * * *', async () => { - await runCronTask('handleFailedPaymentSubscriptions', async () => this.models.user().handleFailedPaymentSubscriptions()); - }); - - cron.schedule('0 14 * * *', async () => { - await runCronTask('handleOversizedAccounts', async () => this.models.user().handleOversizedAccounts()); - }); - } - -} diff --git a/packages/server/src/services/TaskService.test.ts b/packages/server/src/services/TaskService.test.ts new file mode 100644 index 0000000000..295105ace9 --- /dev/null +++ b/packages/server/src/services/TaskService.test.ts @@ -0,0 +1,82 @@ +import config from '../config'; +import { Models } from '../models/factory'; +import { afterAllTests, beforeAllDb, beforeEachDb, expectThrow, models, msleep } from '../utils/testing/testUtils'; +import { Env } from '../utils/types'; +import TaskService, { RunType, Task } from './TaskService'; + +const newService = () => { + return new TaskService(Env.Dev, models(), config()); +}; + +describe('TaskService', function() { + + beforeAll(async () => { + await beforeAllDb('TaskService'); + }); + + afterAll(async () => { + await afterAllTests(); + }); + + beforeEach(async () => { + await beforeEachDb(); + }); + + test('should register a task', async function() { + const service = newService(); + + const task: Task = { + id: 'test', + description: '', + run: (_models: Models) => {}, + schedule: '', + }; + + service.registerTask(task); + + expect(service.tasks['test']).toBeTruthy(); + await expectThrow(async () => service.registerTask(task)); + }); + + test('should run a task', async function() { + const service = newService(); + + let finishTask = false; + let taskHasRan = false; + + const task: Task = { + id: 'test', + description: '', + run: async (_models: Models) => { + const iid = setInterval(() => { + if (finishTask) { + clearInterval(iid); + taskHasRan = true; + } + }, 1); + }, + schedule: '', + }; + + service.registerTask(task); + + expect(service.taskState('test').running).toBe(false); + + const startTime = new Date(); + + void service.runTask('test', RunType.Manual); + expect(service.taskState('test').running).toBe(true); + expect(service.taskState('test').lastCompletionTime).toBeFalsy(); + expect(service.taskState('test').lastRunTime.getTime()).toBeGreaterThanOrEqual(startTime.getTime()); + + await msleep(1); + finishTask = true; + await msleep(3); + + expect(taskHasRan).toBe(true); + expect(service.taskState('test').running).toBe(false); + expect(service.taskState('test').lastCompletionTime.getTime()).toBeGreaterThan(startTime.getTime()); + + }); + +}); diff --git a/packages/server/src/services/TaskService.ts b/packages/server/src/services/TaskService.ts new file mode 100644 index 0000000000..ae427e7c50 --- /dev/null +++ b/packages/server/src/services/TaskService.ts @@ -0,0 +1,108 @@ +import Logger from '@joplin/lib/Logger'; +import { Models } from '../models/factory'; +import BaseService from './BaseService'; +const cron = require('node-cron'); + +const logger = Logger.create('TaskService'); + +type TaskId = string; + +export enum RunType { + Scheduled = 1, + Manual = 2, +} + +const runTypeToString = (runType: RunType) => { + if (runType === RunType.Scheduled) return 'scheduled'; + if (runType === RunType.Manual) return 'manual'; + throw new Error(`Unknown run type: ${runType}`); +}; + +export interface Task { + id: TaskId; + description: string; + schedule: string; + run(models: Models): void; +} + +export type Tasks = Record; + +interface TaskState { + running: boolean; + lastRunTime: Date; + lastCompletionTime: Date; +} + +const defaultTaskState: TaskState = { + running: false, + lastRunTime: null, + lastCompletionTime: null, +}; + +export default class TaskService extends BaseService { + + private tasks_: Tasks = {}; + private taskStates_: Record = {}; + + public registerTask(task: Task) { + if (this.tasks_[task.id]) throw new Error(`Already a task with this ID: ${task.id}`); + this.tasks_[task.id] = task; + this.taskStates_[task.id] = { ...defaultTaskState }; + } + + public registerTasks(tasks: Task[]) { + for (const task of tasks) this.registerTask(task); + } + + public get tasks(): Tasks { + return this.tasks_; + } + + public taskState(id: TaskId): TaskState { + if (!this.taskStates_[id]) throw new Error(`No such task: ${id}`); + return this.taskStates_[id]; + } + + // TODO: add tests + + public async runTask(id: TaskId, runType: RunType) { + const state = this.taskState(id); + if (state.running) throw new Error(`Task is already running: ${id}`); + + const startTime = Date.now(); + + this.taskStates_[id] = { + ...this.taskStates_[id], + running: true, + lastRunTime: new Date(), + }; + + try { + logger.info(`Running "${id}" (${runTypeToString(runType)})...`); + await this.tasks_[id].run(this.models); + } catch (error) { + logger.error(`On task "${id}"`, error); + } + + this.taskStates_[id] = { + ...this.taskStates_[id], + running: false, + lastCompletionTime: new Date(), + }; + + logger.info(`Completed "${id}" in ${Date.now() - startTime}ms`); + } + + public async runInBackground() { + for (const [taskId, task] of Object.entries(this.tasks_)) { + if (!task.schedule) continue; + + logger.info(`Scheduling task "${taskId}": ${task.schedule}`); + + cron.schedule(task.schedule, async () => { + await this.runTask(taskId, RunType.Scheduled); + }); + } + } + +} diff --git a/packages/server/src/services/database/types.ts b/packages/server/src/services/database/types.ts index 6029856e16..54bb987670 100644 --- a/packages/server/src/services/database/types.ts +++ b/packages/server/src/services/database/types.ts @@ -6,6 +6,7 @@ export enum ItemAddressingType { } export enum NotificationLevel { + Error = 5, Important = 10, Normal = 20, } diff --git a/packages/server/src/services/types.ts b/packages/server/src/services/types.ts index f30793eee4..02d2783777 100644 --- a/packages/server/src/services/types.ts +++ b/packages/server/src/services/types.ts @@ -1,11 +1,11 @@ -import CronService from './CronService'; import EmailService from './EmailService'; import MustacheService from './MustacheService'; import ShareService from './ShareService'; +import TaskService from './TaskService'; export interface Services { share: ShareService; email: EmailService; - cron: CronService; mustache: MustacheService; + tasks: TaskService; } diff --git a/packages/server/src/utils/routeUtils.ts b/packages/server/src/utils/routeUtils.ts index cd03862e60..fbb6714fd8 100644 --- a/packages/server/src/utils/routeUtils.ts +++ b/packages/server/src/utils/routeUtils.ts @@ -271,6 +271,7 @@ export enum UrlType { Login = 'login', Terms = 'terms', Privacy = 'privacy', + Tasks = 'tasks', } export function makeUrl(urlType: UrlType): string { diff --git a/packages/server/src/utils/setupAppContext.ts b/packages/server/src/utils/setupAppContext.ts index da274daeab..d3dce8e655 100644 --- a/packages/server/src/utils/setupAppContext.ts +++ b/packages/server/src/utils/setupAppContext.ts @@ -7,15 +7,15 @@ import routes from '../routes/routes'; import ShareService from '../services/ShareService'; import { Services } from '../services/types'; import EmailService from '../services/EmailService'; -import CronService from '../services/CronService'; import MustacheService from '../services/MustacheService'; +import setupTaskService from './setupTaskService'; async function setupServices(env: Env, models: Models, config: Config): Promise { const output: Services = { share: new ShareService(env, models, config), email: new EmailService(env, models, config), - cron: new CronService(env, models, config), mustache: new MustacheService(config.viewDir, config.baseUrl), + tasks: setupTaskService(env, models, config), }; await output.mustache.loadPartials(); diff --git a/packages/server/src/utils/setupTaskService.ts b/packages/server/src/utils/setupTaskService.ts new file mode 100644 index 0000000000..a7374fd3c5 --- /dev/null +++ b/packages/server/src/utils/setupTaskService.ts @@ -0,0 +1,49 @@ +import { Models } from '../models/factory'; +import TaskService, { Task } from '../services/TaskService'; +import { Config, Env } from './types'; + +export default function(env: Env, models: Models, config: Config): TaskService { + const taskService = new TaskService(env, models, config); + + let tasks: Task[] = [ + { + id: 'deleteExpiredTokens', + description: 'Delete expired tokens', + schedule: '0 */6 * * *', + run: (models: Models) => models.token().deleteExpiredTokens(), + }, + { + id: 'updateTotalSizes', + description: 'Update total sizes', + schedule: '0 * * * *', + run: (models: Models) => models.item().updateTotalSizes(), + }, + { + id: 'handleOversizedAccounts', + description: 'Process oversized accounts', + schedule: '0 14 * * *', + run: (models: Models) => models.user().handleOversizedAccounts(), + }, + ]; + + if (config.isJoplinCloud) { + tasks = tasks.concat([ + { + id: 'handleBetaUserEmails', + description: 'Process beta user emails', + schedule: '0 12 * * *', + run: (models: Models) => models.user().handleBetaUserEmails(), + }, + { + id: 'handleFailedPaymentSubscriptions', + description: 'Process failed payment subscriptions', + schedule: '0 13 * * *', + run: (models: Models) => models.user().handleFailedPaymentSubscriptions(), + }, + ]); + } + + taskService.registerTasks(tasks); + + return taskService; +} diff --git a/packages/server/src/utils/startServices.ts b/packages/server/src/utils/startServices.ts index 2d925a6e77..52f29f49e8 100644 --- a/packages/server/src/utils/startServices.ts +++ b/packages/server/src/utils/startServices.ts @@ -3,5 +3,5 @@ import { Services } from '../services/types'; export default async function startServices(services: Services) { void services.share.runInBackground(); void services.email.runInBackground(); - void services.cron.runInBackground(); + void services.tasks.runInBackground(); } diff --git a/packages/server/src/utils/time.ts b/packages/server/src/utils/time.ts index 03dc7c3866..5ce3fe5b27 100644 --- a/packages/server/src/utils/time.ts +++ b/packages/server/src/utils/time.ts @@ -29,7 +29,8 @@ export function msleep(ms: number) { }); } -export function formatDateTime(ms: number): string { +export function formatDateTime(ms: number | Date): string { + ms = ms instanceof Date ? ms.getTime() : ms; return `${dayjs(ms).format('D MMM YY HH:mm:ss')} (${defaultTimezone()})`; } diff --git a/packages/server/src/utils/types.ts b/packages/server/src/utils/types.ts index 437c9be7d6..6669f4693e 100644 --- a/packages/server/src/utils/types.ts +++ b/packages/server/src/utils/types.ts @@ -17,7 +17,7 @@ export enum Env { export interface NotificationView { id: Uuid; messageHtml: string; - level: string; + levelClassName: string; closeUrl: string; } diff --git a/packages/server/src/utils/views/table.ts b/packages/server/src/utils/views/table.ts index a60118bbea..859e446c78 100644 --- a/packages/server/src/utils/views/table.ts +++ b/packages/server/src/utils/views/table.ts @@ -4,11 +4,13 @@ import { setQueryParameters } from '../urlUtils'; const defaultSortOrder = PaginationOrderDir.ASC; function headerIsSelectedClass(name: string, pagination: Pagination): string { + if (!pagination) return ''; const orderBy = pagination.order[0].by; return name === orderBy ? 'is-selected' : ''; } function headerSortIconDir(name: string, pagination: Pagination): string { + if (!pagination) return ''; const orderBy = pagination.order[0].by; const orderDir = orderBy === name ? pagination.order[0].dir : defaultSortOrder; return orderDir === PaginationOrderDir.ASC ? 'up' : 'down'; @@ -35,6 +37,7 @@ interface HeaderView { interface RowItem { value: string; + checkbox?: boolean; url?: string; stretch?: boolean; } @@ -45,6 +48,7 @@ interface RowItemView { value: string; classNames: string[]; url: string; + checkbox: boolean; } type RowView = RowItemView[]; @@ -52,10 +56,10 @@ type RowView = RowItemView[]; export interface Table { headers: Header[]; rows: Row[]; - baseUrl: string; - requestQuery: any; - pageCount: number; - pagination: Pagination; + baseUrl?: string; + requestQuery?: any; + pageCount?: number; + pagination?: Pagination; } export interface TableView { @@ -77,7 +81,7 @@ export function makeTablePagination(query: any, defaultOrderField: string, defau function makeHeaderView(header: Header, parentBaseUrl: string, baseUrlQuery: PaginationQueryParams, pagination: Pagination): HeaderView { return { label: header.label, - sortLink: setQueryParameters(parentBaseUrl, { ...baseUrlQuery, 'order_by': header.name, 'order_dir': headerNextOrder(header.name, pagination) }), + sortLink: !pagination ? null : setQueryParameters(parentBaseUrl, { ...baseUrlQuery, 'order_by': header.name, 'order_dir': headerNextOrder(header.name, pagination) }), classNames: [header.stretch ? 'stretch' : 'nowrap', headerIsSelectedClass(header.name, pagination)], iconDir: headerSortIconDir(header.name, pagination), }; @@ -89,14 +93,21 @@ function makeRowView(row: Row): RowView { value: rowItem.value, classNames: [rowItem.stretch ? 'stretch' : 'nowrap'], url: rowItem.url, + checkbox: rowItem.checkbox, }; }); } export function makeTableView(table: Table): TableView { - const baseUrlQuery = filterPaginationQueryParams(table.requestQuery); - const pagination = table.pagination; - const paginationLinks = createPaginationLinks(pagination.page, table.pageCount, setQueryParameters(table.baseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' })); + let paginationLinks: PageLink[] = []; + let baseUrlQuery: PaginationQueryParams = null; + let pagination: Pagination = null; + + if (table.pageCount) { + baseUrlQuery = filterPaginationQueryParams(table.requestQuery); + pagination = table.pagination; + paginationLinks = createPaginationLinks(pagination.page, table.pageCount, setQueryParameters(table.baseUrl, { ...baseUrlQuery, 'page': 'PAGE_NUMBER' })); + } return { headers: table.headers.map(h => makeHeaderView(h, table.baseUrl, baseUrlQuery, pagination)), diff --git a/packages/server/src/views/index/tasks.mustache b/packages/server/src/views/index/tasks.mustache new file mode 100644 index 0000000000..924dc981e3 --- /dev/null +++ b/packages/server/src/views/index/tasks.mustache @@ -0,0 +1,11 @@ +
+ {{{csrfTag}}} + + {{#itemTable}} + {{>table}} + {{/itemTable}} + +
+ +
+
\ No newline at end of file diff --git a/packages/server/src/views/partials/navbar.mustache b/packages/server/src/views/partials/navbar.mustache index 39bbdf4068..d923fece6a 100644 --- a/packages/server/src/views/partials/navbar.mustache +++ b/packages/server/src/views/partials/navbar.mustache @@ -16,6 +16,9 @@ {{/global.owner.is_admin}} Items Log + {{#global.owner.is_admin}} + Tasks + {{/global.owner.is_admin}}