1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-23 18:53:36 +02:00

Server: Allow enabling and disabling tasks

This commit is contained in:
Laurent Cozic 2022-10-21 11:45:07 +01:00
parent cb4cf92206
commit 1379c9c706
14 changed files with 246 additions and 79 deletions

Binary file not shown.

View File

@ -285,18 +285,18 @@ async function main() {
appLogger().info('Connection check:', connectionCheckLogInfo);
const ctx = app.context as AppContext;
await setupAppContext(ctx, env, connectionCheck.connection, appLogger);
await initializeJoplinUtils(config(), ctx.joplinBase.models, ctx.joplinBase.services.mustache);
if (config().database.autoMigration) {
appLogger().info('Auto-migrating database...');
await migrateLatest(ctx.joplinBase.db);
appLogger().info('Latest migration:', await latestMigration(ctx.joplinBase.db));
await migrateLatest(connectionCheck.connection);
appLogger().info('Latest migration:', await latestMigration(connectionCheck.connection));
} else {
appLogger().info('Skipped database auto-migration.');
}
await setupAppContext(ctx, env, connectionCheck.connection, appLogger);
await initializeJoplinUtils(config(), ctx.joplinBase.models, ctx.joplinBase.services.mustache);
appLogger().info('Performing main storage check...');
appLogger().info(await storageConnectionCheck(config().storageDriver, ctx.joplinBase.db, ctx.joplinBase.models));

View File

@ -0,0 +1,17 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.createTable('task_states', (table: Knex.CreateTableBuilder) => {
table.increments('id').unique().primary().notNullable();
table.integer('task_id').unique().notNullable();
table.specificType('running', 'smallint').defaultTo(0).notNullable();
table.specificType('enabled', 'smallint').defaultTo(1).notNullable();
table.bigInteger('updated_time').notNullable();
table.bigInteger('created_time').notNullable();
});
}
export async function down(db: DbConnection): Promise<any> {
await db.schema.dropTable('task_states');
}

View File

@ -357,7 +357,7 @@ export default abstract class BaseModel<T> {
return toSave;
}
public async loadByIds(ids: string[], options: LoadOptions = {}): Promise<T[]> {
public async loadByIds(ids: string[] | number[], options: LoadOptions = {}): Promise<T[]> {
if (!ids.length) return [];
ids = unique(ids);
return this.db(this.tableName).select(options.fields || this.defaultFields).whereIn('id', ids);

View File

@ -0,0 +1,56 @@
import { TaskId, TaskState } from '../services/database/types';
import BaseModel from './BaseModel';
export default class TaskStateModel extends BaseModel<TaskState> {
public get tableName(): string {
return 'task_states';
}
protected hasUuid(): boolean {
return false;
}
public async loadByTaskId(taskId: TaskId): Promise<TaskState> {
return this.db(this.tableName).where('task_id', '=', taskId).first();
}
public async loadByTaskIds(taskIds: TaskId[]): Promise<TaskState[]> {
return this.db(this.tableName).whereIn('task_id', taskIds);
}
public async init(taskId: TaskId) {
const taskState: TaskState = await this.loadByTaskId(taskId);
if (taskState) return taskState;
return this.save({
task_id: taskId,
enabled: 1,
running: 0,
});
}
public async start(taskId: TaskId) {
const state = await this.loadByTaskId(taskId);
if (state.running) throw new Error(`Task is already running: ${taskId}`);
await this.save({ id: state.id, running: 1 });
}
public async stop(taskId: TaskId) {
const state = await this.loadByTaskId(taskId);
if (!state.running) throw new Error(`Task is not running: ${taskId}`);
await this.save({ id: state.id, running: 0 });
}
public async enable(taskId: TaskId, enabled: boolean = true) {
const state = await this.loadByTaskId(taskId);
if (state.enabled && enabled) throw new Error(`Task is already enabled: ${taskId}`);
if (!state.enabled && !enabled) throw new Error(`Task is already disabled: ${taskId}`);
await this.save({ id: state.id, enabled: enabled ? 1 : 0 });
}
public async disable(taskId: TaskId) {
await this.enable(taskId, false);
}
}

View File

@ -76,6 +76,7 @@ import LockModel from './LockModel';
import StorageModel from './StorageModel';
import UserDeletionModel from './UserDeletionModel';
import BackupItemModel from './BackupItemModel';
import TaskStateModel from './TaskStateModel';
export type NewModelFactoryHandler = (db: DbConnection)=> Models;
@ -175,6 +176,10 @@ export class Models {
return new BackupItemModel(this.db_, this.newModelFactory, this.config_);
}
public taskState() {
return new TaskStateModel(this.db_, this.newModelFactory, this.config_);
}
}
export default function newModelFactory(db: DbConnection, config: Config): Models {

View File

@ -11,44 +11,63 @@ 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';
import { NotificationLevel, TaskId } from '../../services/database/types';
const prettyCron = require('prettycron');
const router: Router = new Router(RouteType.Web);
router.post('admin/tasks', async (_path: SubPath, ctx: AppContext) => {
interface FormFields {
startTaskButton: string;
enableTaskButton: string;
disableTaskButton: string;
}
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);
const fields = await bodyFields<FormFields>(ctx.req);
const taskIds: TaskId[] = [];
for (const k of Object.keys(fields)) {
if (k.startsWith('checkbox_')) {
taskIds.push(Number(k.substr(9)));
}
}
const errors: Error[] = [];
if (fields.startTaskButton) {
const errors: Error[] = [];
for (const k of Object.keys(fields)) {
if (k.startsWith('checkbox_')) {
const taskId = Number(k.substr(9));
try {
void taskService.runTask(taskId, RunType.Manual);
} catch (error) {
errors.push(error);
}
for (const taskId of taskIds) {
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 if (fields.enableTaskButton || fields.disableTaskButton) {
for (const taskId of taskIds) {
try {
await taskService.enableTask(taskId, !!fields.enableTaskButton);
} catch (error) {
errors.push(error);
}
}
} else {
throw new ErrorBadRequest('Invalid action');
}
if (errors.length) {
await ctx.joplin.models.notification().add(
user.id,
NotificationKey.Any,
NotificationLevel.Error,
`Some operations could not be performed: ${errors.join('. ')}`
);
}
return redirect(ctx, makeUrl(UrlType.Tasks));
});
@ -57,11 +76,12 @@ router.get('admin/tasks', async (_path: SubPath, ctx: AppContext) => {
if (!user.is_admin) throw new ErrorForbidden();
const taskService = ctx.joplin.services.tasks;
const states = await ctx.joplin.models.taskState().loadByTaskIds(taskService.taskIds);
const taskRows: Row[] = [];
for (const [taskIdString, task] of Object.entries(taskService.tasks)) {
const taskId = Number(taskIdString);
const state = taskService.taskState(taskId);
const state = states.find(s => s.task_id === taskId);
const events = await taskService.taskLastEvents(taskId);
taskRows.push({
@ -80,6 +100,9 @@ router.get('admin/tasks', async (_path: SubPath, ctx: AppContext) => {
value: task.schedule,
hint: prettyCron.toString(task.schedule),
},
{
value: yesOrNo(state.enabled),
},
{
value: yesOrNo(state.running),
},
@ -111,6 +134,10 @@ router.get('admin/tasks', async (_path: SubPath, ctx: AppContext) => {
name: 'schedule',
label: 'Schedule',
},
{
name: 'enabled',
label: 'Enabled',
},
{
name: 'running',
label: 'Running',
@ -134,7 +161,6 @@ router.get('admin/tasks', async (_path: SubPath, ctx: AppContext) => {
postUrl: makeUrl(UrlType.Tasks),
csrfTag: await createCsrfTag(ctx),
},
// cssFiles: ['index/tasks'],
};
});

View File

@ -38,7 +38,7 @@ describe('TaskService', function() {
schedule: '',
};
service.registerTask(task);
await service.registerTask(task);
expect(service.tasks[123456]).toBeTruthy();
await expectThrow(async () => service.registerTask(task));
@ -47,6 +47,8 @@ describe('TaskService', function() {
test('should run a task', async function() {
const service = newService();
let taskStarted = false;
let waitToFinish = true;
let finishTask = false;
let taskHasRan = false;
@ -56,7 +58,11 @@ describe('TaskService', function() {
id: taskId,
description: '',
run: async (_models: Models) => {
taskStarted = true;
const iid = setInterval(() => {
if (waitToFinish) return;
if (finishTask) {
clearInterval(iid);
taskHasRan = true;
@ -66,25 +72,57 @@ describe('TaskService', function() {
schedule: '',
};
service.registerTask(task);
await service.registerTask(task);
expect(service.taskState(taskId).running).toBe(false);
expect((await service.taskState(taskId)).running).toBe(0);
const startTime = new Date();
void service.runTask(taskId, RunType.Manual);
expect(service.taskState(taskId).running).toBe(true);
while (!taskStarted) {
await msleep(1);
}
expect((await service.taskState(taskId)).running).toBe(1);
waitToFinish = false;
while (!taskHasRan) {
await msleep(1);
finishTask = true;
}
expect(service.taskState(taskId).running).toBe(false);
expect((await service.taskState(taskId)).running).toBe(0);
const events = await service.taskLastEvents(taskId);
expect(events.taskStarted.created_time).toBeGreaterThanOrEqual(startTime.getTime());
expect(events.taskCompleted.created_time).toBeGreaterThan(startTime.getTime());
});
test('should not run if task is disabled', async function() {
const service = newService();
let taskHasRan = false;
const taskId = 123456;
const task: Task = {
id: taskId,
description: '',
run: async (_models: Models) => {
taskHasRan = true;
},
schedule: '',
};
await service.registerTask(task);
await service.runTask(taskId, RunType.Manual);
expect(taskHasRan).toBe(true);
taskHasRan = false;
await models().taskState().disable(task.id);
await service.runTask(taskId, RunType.Manual);
expect(taskHasRan).toBe(false);
});
});

View File

@ -2,25 +2,14 @@ import Logger from '@joplin/lib/Logger';
import { Models } from '../models/factory';
import { Config, Env } from '../utils/types';
import BaseService from './BaseService';
import { Event, EventType } from './database/types';
import { Event, EventType, TaskId, TaskState } from './database/types';
import { Services } from './types';
import { _ } from '@joplin/lib/locale';
import { ErrorNotFound } from '../utils/errors';
const cron = require('node-cron');
const logger = Logger.create('TaskService');
export enum TaskId {
DeleteExpiredTokens = 1,
UpdateTotalSizes = 2,
HandleOversizedAccounts = 3,
HandleBetaUserEmails = 4,
HandleFailedPaymentSubscriptions = 5,
DeleteExpiredSessions = 6,
CompressOldChanges = 7,
ProcessUserDeletions = 8,
AutoAddDisabledAccountsForDeletion = 9,
}
export enum RunType {
Scheduled = 1,
Manual = 2,
@ -60,14 +49,6 @@ export interface Task {
export type Tasks = Record<number, Task>;
interface TaskState {
running: boolean;
}
const defaultTaskState: TaskState = {
running: false,
};
interface TaskEvents {
taskStarted: Event;
taskCompleted: Event;
@ -76,7 +57,6 @@ interface TaskEvents {
export default class TaskService extends BaseService {
private tasks_: Tasks = {};
private taskStates_: Record<number, TaskState> = {};
private services_: Services;
public constructor(env: Env, models: Models, config: Config, services: Services) {
@ -84,23 +64,32 @@ export default class TaskService extends BaseService {
this.services_ = services;
}
public registerTask(task: Task) {
public async 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 };
await this.models.taskState().init(task.id);
}
public registerTasks(tasks: Task[]) {
for (const task of tasks) this.registerTask(task);
public async registerTasks(tasks: Task[]) {
for (const task of tasks) await 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];
public get taskIds(): TaskId[] {
return Object.keys(this.tasks_).map(s => Number(s));
}
public async taskStates(ids: TaskId[]): Promise<TaskState[]> {
return this.models.taskState().loadByTaskIds(ids);
}
public async taskState(id: TaskId): Promise<TaskState> {
const r = await this.taskStates([id]);
if (!r.length) throw new ErrorNotFound(`No such task: ${id}`);
return r[0];
}
public async taskLastEvents(id: TaskId): Promise<TaskEvents> {
@ -122,16 +111,16 @@ export default class TaskService extends BaseService {
public async runTask(id: TaskId, runType: RunType) {
const displayString = this.taskDisplayString(id);
const state = this.taskState(id);
if (state.running) throw new Error(`Already running: ${displayString}`);
const taskState = await this.models.taskState().loadByTaskId(id);
if (!taskState.enabled) {
logger.info(`Not running ${displayString} because the tasks is disabled`);
return;
}
await this.models.taskState().start(id);
const startTime = Date.now();
this.taskStates_[id] = {
...this.taskStates_[id],
running: true,
};
await this.models.event().create(EventType.TaskStarted, id.toString());
try {
@ -141,16 +130,16 @@ export default class TaskService extends BaseService {
logger.error(`On ${displayString}`, error);
}
this.taskStates_[id] = {
...this.taskStates_[id],
running: false,
};
await this.models.taskState().stop(id);
await this.models.event().create(EventType.TaskCompleted, id.toString());
logger.info(`Completed ${this.taskDisplayString(id)} in ${Date.now() - startTime}ms`);
}
public async enableTask(taskId: TaskId, enabled: boolean = true) {
await this.models.taskState().enable(taskId, enabled);
}
public async runInBackground() {
for (const [taskId, task] of Object.entries(this.tasks_)) {
if (!task.schedule) continue;

View File

@ -111,6 +111,20 @@ interface DatabaseTables {
[key: string]: DatabaseTable;
}
export enum TaskId {
// Don't re-use any of these numbers, always add to it, as the ID is used in
// the database
DeleteExpiredTokens = 1,
UpdateTotalSizes = 2,
HandleOversizedAccounts = 3,
HandleBetaUserEmails = 4,
HandleFailedPaymentSubscriptions = 5,
DeleteExpiredSessions = 6,
CompressOldChanges = 7,
ProcessUserDeletions = 8,
AutoAddDisabledAccountsForDeletion = 9,
}
// AUTO-GENERATED-TYPES
// Auto-generated using `yarn run generate-types`
export interface Session extends WithDates, WithUuid {
@ -300,6 +314,13 @@ export interface BackupItem extends WithCreatedDate {
content?: Buffer;
}
export interface TaskState extends WithDates {
id?: number;
task_id?: TaskId;
running?: number;
enabled?: number;
}
export const databaseSchema: DatabaseTables = {
sessions: {
id: { type: 'string' },
@ -504,5 +525,13 @@ export const databaseSchema: DatabaseTables = {
content: { type: 'any' },
created_time: { type: 'string' },
},
task_states: {
id: { type: 'number' },
task_id: { type: 'number' },
running: { type: 'number' },
enabled: { type: 'number' },
updated_time: { type: 'string' },
created_time: { type: 'string' },
},
};
// AUTO-GENERATED-TYPES

View File

@ -36,6 +36,7 @@ const config = {
'main.users': 'WithDates, WithUuid',
'main.events': 'WithUuid',
'main.user_deletions': 'WithDates',
'main.task_states': 'WithDates',
'main.backup_items': 'WithCreatedDate',
},
};
@ -65,6 +66,7 @@ const propertyTypes: Record<string, string> = {
'user_deletions.scheduled_time': 'number',
'users.disabled_time': 'number',
'backup_items.content': 'Buffer',
'task_states.task_id': 'TaskId',
};
function insertContentIntoFile(filePath: string, markerOpen: string, markerClose: string, contentToInsert: string): void {
@ -158,6 +160,8 @@ async function main() {
content += `export const databaseSchema: DatabaseTables = {\n${tableStrings.join('\n')}\n};`;
insertContentIntoFile(dbFilePath, fileReplaceWithinMarker, fileReplaceWithinMarker, content);
console.info(`Types have been updated in ${dbFilePath}`);
}
main().catch(error => {

View File

@ -20,7 +20,7 @@ async function setupServices(env: Env, models: Models, config: Config): Promise<
tasks: null,
};
output.tasks = setupTaskService(env, models, config, output),
output.tasks = await setupTaskService(env, models, config, output),
await output.mustache.loadPartials();

View File

@ -1,9 +1,10 @@
import { Models } from '../models/factory';
import TaskService, { Task, TaskId, taskIdToLabel } from '../services/TaskService';
import { TaskId } from '../services/database/types';
import TaskService, { Task, taskIdToLabel } from '../services/TaskService';
import { Services } from '../services/types';
import { Config, Env } from './types';
export default function(env: Env, models: Models, config: Config, services: Services): TaskService {
export default async function(env: Env, models: Models, config: Config, services: Services): Promise<TaskService> {
const taskService = new TaskService(env, models, config, services);
let tasks: Task[] = [
@ -80,7 +81,7 @@ export default function(env: Env, models: Models, config: Config, services: Serv
]);
}
taskService.registerTasks(tasks);
await taskService.registerTasks(tasks);
return taskService;
}

View File

@ -7,5 +7,7 @@
<div class="block">
<input class="button is-link" type="submit" value="Start selected tasks" name="startTaskButton"/>
<input class="button is-link" type="submit" value="Enabled selected tasks" name="enableTaskButton"/>
<input class="button is-link" type="submit" value="Disable selected tasks" name="disableTaskButton"/>
</div>
</form>