core: add task manager

This commit is contained in:
undefined committed 2021-05-17 23:56:34 +08:00
1 parent e5b1304bd9
commit 2b4b7de28d
6 files changed
+152 -171

No files matched your search

+5 -5
View File
@@ -29,7 +29,7 @@
"@shelf/jest-mongodb": "^1.2.3", "@shelf/jest-mongodb": "^1.2.3",
"@types/cross-spawn": "^6.0.2", "@types/cross-spawn": "^6.0.2",
"@types/jest": "^26.0.23", "@types/jest": "^26.0.23",
"@types/node": "^15.0.2", "@types/node": "^15.3.0",
"@types/semver": "^7.3.5", "@types/semver": "^7.3.5",
"@typescript-eslint/eslint-plugin": "^4.22.1", "@typescript-eslint/eslint-plugin": "^4.22.1",
"@typescript-eslint/parser": "^4.22.1", "@typescript-eslint/parser": "^4.22.1",
@@ -38,17 +38,17 @@
"eslint": "^7.26.0", "eslint": "^7.26.0",
"eslint-config-airbnb-typescript": "^12.0.0", "eslint-config-airbnb-typescript": "^12.0.0",
"eslint-import-resolver-typescript": "^2.4.0", "eslint-import-resolver-typescript": "^2.4.0",
"eslint-plugin-import": "^2.22.1", "eslint-plugin-import": "^2.23.2",
"fs-extra": "^9.1.0", "fs-extra": "^10.0.0",
"globby": "^11.0.3", "globby": "^11.0.3",
"jest": "^26.6.3", "jest": "^26.6.3",
"latest-version": "^5.1.0", "latest-version": "^5.1.0",
"nmls": "^2.0.3", "nmls": "^3.0.0",
"ora": "^5.4.0", "ora": "^5.4.0",
"semver": "^7.3.5", "semver": "^7.3.5",
"ts-jest": "^26.5.6", "ts-jest": "^26.5.6",
"typedoc": "^0.20.36", "typedoc": "^0.20.36",
"typescript": "4.2.4", "typescript": "4.2.4",
"yargs": "^16.2.0" "yargs": "^17.0.1"
} }
} }
+1 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "hydrooj", "name": "hydrooj",
"version": "2.26.8", "version": "2.26.9",
"bin": "bin/hydrooj.js", "bin": "bin/hydrooj.js",
"main": "dist/loader.js", "main": "dist/loader.js",
"typings": "dist/loader.d.ts", "typings": "dist/loader.d.ts",
@@ -17,7 +17,6 @@
"cookies": "^0.8.0", "cookies": "^0.8.0",
"detect-browser": "^5.1.1", "detect-browser": "^5.1.1",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"inquirer": "^8.0.0",
"js-yaml": "^4.0.0", "js-yaml": "^4.0.0",
"koa": "^2.13.1", "koa": "^2.13.1",
"koa-body": "^4.2.0", "koa-body": "^4.2.0",
@@ -41,7 +40,6 @@
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.4.34", "@types/adm-zip": "^0.4.34",
"@types/fs-extra": "^9.0.10", "@types/fs-extra": "^9.0.10",
"@types/inquirer": "^7.3.1",
"@types/js-yaml": "^4.0.0", "@types/js-yaml": "^4.0.0",
"@types/koa": "^2.13.1", "@types/koa": "^2.13.1",
"@types/koa-compress": "^4.0.1", "@types/koa-compress": "^4.0.1",
+56 -54
View File
@@ -11,6 +11,7 @@ import { ValidationError, RemoteOnlineJudgeError } from '../error';
import { Logger } from '../logger'; import { Logger } from '../logger';
import type { ContentNode } from '../interface'; import type { ContentNode } from '../interface';
import problem from '../model/problem'; import problem from '../model/problem';
import TaskModel from '../model/task';
import { PERM } from '../model/builtin'; import { PERM } from '../model/builtin';
import { import {
Route, Handler, Types, post, Route, Handler, Types, post,
@@ -21,6 +22,52 @@ import download from '../lib/download';
const RE_SYZOJ = /(https?):\/\/([^/]+)\/(problem|p)\/([0-9]+)\/?/i; const RE_SYZOJ = /(https?):\/\/([^/]+)\/(problem|p)\/([0-9]+)\/?/i;
const logger = new Logger('import.syzoj'); const logger = new Logger('import.syzoj');
async function syncFiles(info) {
const {
protocol, host, body, pid, domainId, docId,
} = info;
const judge = body.judgeInfo;
const r = await superagent.post(`${protocol}://${host === 'loj.ac' ? 'api.loj.ac.cn' : host}/api/problem/downloadProblemFiles`)
.send({
problemId: +pid,
type: 'TestData',
filenameList: body.testData.map((node) => node.filename),
});
const urls = {};
if (r.body.error) return;
for (const t of r.body.downloadInfo) urls[t.filename] = t.downloadUrl;
for (const f of body.testData) {
const p = new PassThrough();
superagent.get(urls[f.filename]).pipe(p);
// eslint-disable-next-line no-await-in-loop
await problem.addTestdata(domainId, docId, f.filename, p);
}
if (judge) {
const config = {
time: `${judge.timeLimit}ms`,
memory: `${judge.memoryLimit}m`,
// TODO other config
};
await problem.addTestdata(domainId, docId, 'config.yaml', Buffer.from(yaml.dump(config)));
}
const a = await superagent.post(`${protocol}://${host === 'loj.ac' ? 'api.loj.ac.cn' : host}/api/problem/downloadProblemFiles`)
.send({
problemId: +pid,
type: 'AdditionalFile',
filenameList: body.additionalFiles.map((node) => node.filename),
});
const aurls = {};
if (a.body.error) return;
for (const t of a.body.downloadInfo) aurls[t.filename] = t.downloadUrl;
for (const f of body.additionalFiles) {
const p = new PassThrough();
superagent.get(aurls[f.filename]).pipe(p);
// eslint-disable-next-line no-await-in-loop
await problem.addAdditionalFile(domainId, docId, f.filename, p);
}
}
TaskModel.Worker.addHandler('import.syzoj', syncFiles);
class ProblemImportSYZOJHandler extends Handler { class ProblemImportSYZOJHandler extends Handler {
async get() { async get() {
this.response.template = 'problem_import_syzoj.html'; this.response.template = 'problem_import_syzoj.html';
@@ -174,49 +221,11 @@ class ProblemImportSYZOJHandler extends Handler {
const docId = await problem.add( const docId = await problem.add(
domainId, target, title, JSON.stringify(content), this.user._id, tags || [], hidden, domainId, target, title, JSON.stringify(content), this.user._id, tags || [], hidden,
); );
const syncFiles = async () => { const payload = {
const judge = result.body.judgeInfo; protocol, host, pid, domainId, docId, body: result.body,
const r = await superagent.post(`${protocol}://${host === 'loj.ac' ? 'api.loj.ac.cn' : host}/api/problem/downloadProblemFiles`)
.send({
problemId: +pid,
type: 'TestData',
filenameList: result.body.testData.map((node) => node.filename),
});
const urls = {};
if (r.body.error) return;
for (const t of r.body.downloadInfo) urls[t.filename] = t.downloadUrl;
for (const f of result.body.testData) {
const p = new PassThrough();
superagent.get(urls[f.filename]).pipe(p);
// eslint-disable-next-line no-await-in-loop
await problem.addTestdata(domainId, docId, f.filename, p);
}
if (judge) {
const config = {
time: `${judge.timeLimit}ms`,
memory: `${judge.memoryLimit}m`,
// TODO other config
};
await problem.addTestdata(domainId, docId, 'config.yaml', Buffer.from(yaml.dump(config)));
}
const a = await superagent.post(`${protocol}://${host === 'loj.ac' ? 'api.loj.ac.cn' : host}/api/problem/downloadProblemFiles`)
.send({
problemId: +pid,
type: 'AdditionalFile',
filenameList: result.body.additionalFiles.map((node) => node.filename),
});
const aurls = {};
if (a.body.error) return;
for (const t of a.body.downloadInfo) aurls[t.filename] = t.downloadUrl;
for (const f of result.body.additionalFiles) {
const p = new PassThrough();
superagent.get(aurls[f.filename]).pipe(p);
// eslint-disable-next-line no-await-in-loop
await problem.addAdditionalFile(domainId, docId, f.filename, p);
}
}; };
if (wait) await syncFiles(); if (wait) await syncFiles(payload);
else syncFiles().catch(logger.error); else await TaskModel.add({ ...payload, type: 'schedule', subType: 'import.syzoj' });
return docId; return docId;
} }
@@ -252,18 +261,11 @@ class ProblemImportSYZOJHandler extends Handler {
assert(url.match(RE_SYZOJ), new ValidationError('url')); assert(url.match(RE_SYZOJ), new ValidationError('url'));
if (!url.endsWith('/')) url += '/'; if (!url.endsWith('/')) url += '/';
const [, protocol, host, n, pid] = RE_SYZOJ.exec(url); const [, protocol, host, n, pid] = RE_SYZOJ.exec(url);
if (n === 'p') { const docId = n === 'p'
const docId = await this.v3( ? await this.v3(domainId, targetPid, hidden, protocol, host, pid, false)
domainId, targetPid, hidden, : await this.v2(domainId, targetPid, hidden, url);
protocol, host, pid, false, this.response.body = { pid: targetPid || docId };
); this.response.redirect = this.url('problem_detail', { pid: targetPid || docId });
this.response.body = { pid: targetPid || docId };
this.response.redirect = this.url('problem_files', { pid: targetPid || docId });
} else {
const docId = await this.v2(domainId, targetPid, hidden, url);
this.response.body = { pid: targetPid || docId };
this.response.redirect = this.url('problem_files', { pid: targetPid || docId });
}
} }
} }
} }
+83 -60
View File
@@ -1,12 +1,89 @@
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import { FilterQuery, ObjectID } from 'mongodb'; import { FilterQuery, ObjectID } from 'mongodb';
import { Task } from '../interface'; import { BaseService, Task } from '../interface';
import { Logger } from '../logger'; import { Logger } from '../logger';
import db from '../service/db'; import db from '../service/db';
const logger = new Logger('model/task'); const logger = new Logger('model/task');
const coll = db.collection('task'); const coll = db.collection('task');
async function getFirst(query: FilterQuery<Task>) {
const q = { ...query };
q.executeAfter = q.executeAfter || { $lt: new Date() };
const res = await coll.findOneAndDelete(q, { sort: { priority: -1 } });
if (res.value) {
logger.debug('%o', res.value);
if (res.value.interval) {
const executeAfter = moment(res.value.executeAfter).add(...res.value.interval).toDate();
await coll.insertOne({ ...res.value, executeAfter });
}
return res.value;
}
return null;
}
class Consumer {
consuming: boolean;
running?: any;
filter: any;
func: Function;
timeout: NodeJS.Timeout;
constructor(filter: any, func: Function) {
this.consuming = true;
this.filter = filter;
this.func = func;
this.get = this.get.bind(this);
this.get();
}
async get() {
if (this.running || !this.consuming) return;
try {
const res = await getFirst(this.filter);
if (res) {
this.running = res;
await this.func(res);
this.running = null;
}
} catch (e) {
logger.error(e);
this.destory();
}
this.timeout = setTimeout(this.get, 100);
}
async destory() {
this.consuming = false;
clearTimeout(this.timeout);
}
}
class WorkerService implements BaseService {
private handlers: Record<string, Function> = {};
public consumer: Consumer;
public started = false;
public start() {
this.consumer = new Consumer(
{ type: 'schedule', subType: { $in: Object.keys(this.handlers) } },
(doc) => {
logger.debug('Worker task: %o', doc);
return this.handlers[doc.subType](doc);
},
);
this.started = true;
}
public addHandler(type: string, handler: Function) {
this.handlers[type] = handler;
this.consumer.filter = { type: 'schedule', subType: { $in: Object.keys(this.handlers) } };
}
}
const Worker = new WorkerService();
Worker.start();
class TaskModel { class TaskModel {
static async add(task: Partial<Task> & { type: string }) { static async add(task: Partial<Task> & { type: string }) {
const t: Task = { const t: Task = {
@@ -32,21 +109,7 @@ class TaskModel {
return coll.deleteOne({ _id }); return coll.deleteOne({ _id });
} }
static async getFirst(query: FilterQuery<Task>) { static getFirst = getFirst;
const q = { ...query };
q.executeAfter = q.executeAfter || { $lt: new Date() };
const res = await coll.findOneAndDelete(q, { sort: { priority: -1 } });
if (res.value) {
logger.debug('%o', res.value);
if (res.value.interval) {
await coll.insertOne({
...res.value, executeAfter: moment().add(...res.value.interval).toDate(),
});
}
return res.value;
}
return null;
}
static async getDelay(query?: FilterQuery<Task>) { static async getDelay(query?: FilterQuery<Task>) {
const now = new Date(); const now = new Date();
@@ -56,52 +119,12 @@ class TaskModel {
} }
static async consume(query: any, cb: Function) { static async consume(query: any, cb: Function) {
let isRunning = false; return new Consumer(query, cb);
const interval = setInterval(async () => {
if (isRunning) return;
isRunning = true;
const res = await TaskModel.getFirst(query);
if (res) {
try {
await cb(res);
} catch (e) {
clearInterval(interval);
}
}
isRunning = false;
}, 100);
} }
Consumer = class Consumer { static Consumer = Consumer;
consuming: boolean; static WorkerService = WorkerService;
running?: Promise<any>; static Worker = Worker;
interval: NodeJS.Timeout;
filter: any;
func: Function;
constructor(filter: any, func: Function) {
this.consuming = true;
this.filter = filter;
this.func = func;
this.interval = setInterval(this.consume.bind(this), 100);
}
async consume() {
if (this.running || !this.consuming) return;
const res = await TaskModel.getFirst(this.filter);
if (res) {
this.running = this.func(res);
if (this.running instanceof Promise) await this.running;
this.running = null;
}
}
async destory() {
this.consuming = false;
clearInterval(this.interval);
if (this.running) await this.running;
}
};
} }
export default TaskModel; export default TaskModel;
+1 -43
View File
@@ -1,12 +1,8 @@
/* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable @typescript-eslint/no-shadow */
import cluster from 'cluster'; import cluster from 'cluster';
import { argv } from 'yargs'; import { argv } from 'yargs';
import BottomBar from 'inquirer/lib/ui/bottom-bar';
import { Logger } from './logger';
import * as bus from './service/bus'; import * as bus from './service/bus';
const logger = new Logger('ui');
const useTerminal = cluster.isMaster && process.stdout.isTTY;
const disabledTerminal = argv.legacy || argv._.length || process.env.NODE_ENV === 'test'; const disabledTerminal = argv.legacy || argv._.length || process.env.NODE_ENV === 'test';
export namespace Progress { export namespace Progress {
@@ -43,45 +39,7 @@ async function terminate() {
process.exit(hasError ? 1 : 0); process.exit(hasError ? 1 : 0);
} }
process.on('SIGINT', terminate); process.on('SIGINT', terminate);
if (cluster.isMaster) {
if (useTerminal && !disabledTerminal) {
let current = 0;
const history = [''];
let input = '';
const ui = new BottomBar({ bottomBar: '>' });
process.stdin.on('data', (data) => {
// Control Seq?
const seq = data.toString('hex');
if (seq === '7f') input = input.substr(0, input.length - 1);
else if (seq === '1b5b41') {
// Arrow up
current--;
input = history[current];
} else if (seq === '1b5b42') {
// Arrow down
current++;
input = history[current];
} else if (seq === '0d') {
// Enter
history.push(input);
current = history.length;
if (input[0] === '@') {
for (const i in cluster.workers) {
cluster.workers[i].send({ event: 'message/run', payload: [input.substr(1, input.length - 1)] });
break;
}
} else bus.parallel('message/run', input);
input = '';
} else input += data.toString();
ui.updateBottomBar(`>${input}`);
});
bus.on('message/log', (message) => {
ui.log.write(message);
});
if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION) {
logger.warn('Running over ssh detected. Add a --legacy when starting if GUI mode doesn\'t work properly.');
}
} else if (cluster.isMaster) {
if (!disabledTerminal) console.log('Not running in a terminal environment. Interactive mode disabled.'); if (!disabledTerminal) console.log('Not running in a terminal environment. Interactive mode disabled.');
bus.on('message/log', (message) => { bus.on('message/log', (message) => {
process.stdout.write(`${message}\n`); process.stdout.write(`${message}\n`);
+6 -6
View File
@@ -8,7 +8,7 @@
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.13.14", "@babel/cli": "^7.13.14",
"@babel/core": "^7.14.0", "@babel/core": "^7.14.0",
"@babel/eslint-parser": "7.13.14", "@babel/eslint-parser": "7.14.2",
"@babel/plugin-proposal-class-properties": "^7.13.0", "@babel/plugin-proposal-class-properties": "^7.13.0",
"@babel/plugin-proposal-export-namespace-from": "^7.12.13", "@babel/plugin-proposal-export-namespace-from": "^7.12.13",
"@babel/plugin-proposal-function-sent": "^7.12.13", "@babel/plugin-proposal-function-sent": "^7.12.13",
@@ -44,13 +44,13 @@
"eslint-config-airbnb": "^18.2.1", "eslint-config-airbnb": "^18.2.1",
"eslint-import-resolver-webpack": "^0.13.0", "eslint-import-resolver-webpack": "^0.13.0",
"eslint-plugin-babel": "^5.3.1", "eslint-plugin-babel": "^5.3.1",
"eslint-plugin-import": "^2.22.1", "eslint-plugin-import": "^2.23.2",
"eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.23.1", "eslint-plugin-react": "^7.23.1",
"fancy-log": "^1.3.3", "fancy-log": "^1.3.3",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"friendly-errors-webpack-plugin": "^1.7.0", "friendly-errors-webpack-plugin": "^1.7.0",
"fs-extra": "^9.1.0", "fs-extra": "^10.0.0",
"gulp": "^4.0.2", "gulp": "^4.0.2",
"gulp-cli": "^2.3.0", "gulp-cli": "^2.3.0",
"gulp-iconfont": "^11.0.0", "gulp-iconfont": "^11.0.0",
@@ -64,7 +64,7 @@
"jquery.transit": "^0.9.12", "jquery.transit": "^0.9.12",
"mini-css-extract-plugin": "^1.6.0", "mini-css-extract-plugin": "^1.6.0",
"moment": "^2.29.1", "moment": "^2.29.1",
"monaco-editor": "^0.23.0", "monaco-editor": "^0.24.0",
"monaco-editor-webpack-plugin": "^3.0.1", "monaco-editor-webpack-plugin": "^3.0.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
@@ -100,14 +100,14 @@
"vinyl-buffer": "^1.0.1", "vinyl-buffer": "^1.0.1",
"wastyle": "^0.0.5", "wastyle": "^0.0.5",
"webpack": "^4.44.2", "webpack": "^4.44.2",
"webpack-bundle-analyzer": "^4.4.0", "webpack-bundle-analyzer": "^4.4.2",
"webpack-dev-server": "^3.11.2", "webpack-dev-server": "^3.11.2",
"webpackbar": "^5.0.0-3" "webpackbar": "^5.0.0-3"
}, },
"dependencies": { "dependencies": {
"98.css": "^0.1.16", "98.css": "^0.1.16",
"js-yaml": "^4.0.0", "js-yaml": "^4.0.0",
"katex": "^0.13.9", "katex": "^0.13.11",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"markdown-it": "^12.0.6", "markdown-it": "^12.0.6",
"markdown-it-anchor": "^7.1.0", "markdown-it-anchor": "^7.1.0",