From 0d7c53b927f2dc3fa7fb3ecd56e2afa78168785b Mon Sep 17 00:00:00 2001 From: undefined Date: Sun, 2 May 2021 18:44:15 +0800 Subject: [PATCH] core: performance: userCache&scoreboard.pagination --- packages/hydrooj/package.json | 2 + packages/hydrooj/src/handler/contest.ts | 56 ++++----- packages/hydrooj/src/handler/homework.ts | 45 ++++---- packages/hydrooj/src/handler/record.ts | 24 ++-- packages/hydrooj/src/handler/training.ts | 2 +- packages/hydrooj/src/interface.ts | 20 ++-- packages/hydrooj/src/lib/paginate.ts | 6 +- packages/hydrooj/src/lib/rank.ts | 51 ++++++++- packages/hydrooj/src/model/contest.ts | 140 ++++++++++++----------- packages/hydrooj/src/model/discussion.ts | 9 +- packages/hydrooj/src/model/user.ts | 43 ++++++- packages/hydrooj/src/script/rating.ts | 12 +- 12 files changed, 244 insertions(+), 166 deletions(-) diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 3d1916b83..80add5801 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -26,6 +26,7 @@ "koa-router": "^10.0.0", "koa-static-cache": "^5.1.3", "lodash": "^4.17.21", + "lru-cache": "^6.0.0", "minio": "7.0.17", "moment-timezone": "^0.5.32", "mongodb": "^3.6.6", @@ -47,6 +48,7 @@ "@types/koa-router": "^7.4.1", "@types/koa-static-cache": "^5.1.0", "@types/lodash": "^4.14.168", + "@types/lru-cache": "^5.1.0", "@types/minio": "^7.0.7", "@types/mongodb": "^3.6.12", "@types/nodemailer": "^6.4.1", diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 6f35e5f4d..d5266c9f7 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -6,6 +6,7 @@ import { ContestNotLiveError, ValidationError, ProblemNotFoundError, ContestNotAttendedError, PermissionError, } from '../error'; +import { Pdoc, Tdoc, User } from '../interface'; import paginate from '../lib/paginate'; import { PERM, PRIV } from '../model/builtin'; import * as contest from '../model/contest'; @@ -22,9 +23,7 @@ import { import * as bus from '../service/bus'; import storage from '../service/storage'; -const ContestHandler = contest.ContestHandlerMixin(Handler); - -class ContestListHandler extends ContestHandler { +class ContestListHandler extends Handler { @param('rule', Types.Range(contest.RULES), true) @param('page', Types.PositiveInt, true) async get(domainId: string, rule = '', page = 1) { @@ -54,7 +53,7 @@ class ContestListHandler extends ContestHandler { } } -class ContestDetailHandler extends ContestHandler { +class ContestDetailHandler extends Handler { @param('tid', Types.ObjectID) @param('page', Types.PositiveInt, true) async get(domainId: string, tid: ObjectID, page = 1) { @@ -70,7 +69,7 @@ class ContestDetailHandler extends ContestHandler { if (tsdoc) { attended = tsdoc.attend === 1; for (const pdetail of tsdoc.journal || []) psdict[pdetail.pid] = pdetail; - if (this.canShowRecord(tdoc)) { + if (contest.canShowRecord.call(this, tdoc)) { const q = []; for (const i in psdict) q.push(psdict[i].rid); rdict = await record.getList(domainId, q, true); @@ -106,7 +105,7 @@ class ContestDetailHandler extends ContestHandler { } } -class ContestBoardcastHandler extends ContestHandler { +class ContestBoardcastHandler extends Handler { @param('tid', Types.ObjectID) async get(domainId: string, tid: ObjectID) { const tdoc = await contest.get(domainId, tid); @@ -135,10 +134,11 @@ class ContestBoardcastHandler extends ContestHandler { } } -class ContestScoreboardHandler extends ContestHandler { +class ContestScoreboardHandler extends Handler { @param('tid', Types.ObjectID) - async get(domainId: string, tid: ObjectID) { - const [tdoc, rows, udict] = await this.getScoreboard(domainId, tid); + @param('page', Types.PositiveInt) + async get(domainId: string, tid: ObjectID, page = 1) { + const [tdoc, rows, udict] = await contest.getScoreboard(domainId, tid, false, page); const path = [ ['Hydro', 'homepage'], ['contest_main', 'contest_main'], @@ -152,14 +152,7 @@ class ContestScoreboardHandler extends ContestHandler { } } -class ContestScoreboardRawHandler extends ContestHandler { - @param('tid', Types.ObjectID) - async get(domainId: string, tid: ObjectID) { - this.response.body = await this.getRawStatus(domainId, tid); - } -} - -class ContestScoreboardDownloadHandler extends ContestHandler { +class ContestScoreboardDownloadHandler extends Handler { @param('tid', Types.ObjectID) @param('ext', Types.Range(['csv', 'html'])) async get(domainId: string, tid: ObjectID, ext: string) { @@ -167,12 +160,14 @@ class ContestScoreboardDownloadHandler extends ContestHandler { csv: async (rows) => `\uFEFF${rows.map((c) => (c.map((i) => i.value).join(','))).join('\n')}`, html: (rows) => this.renderHTML('contest_scoreboard_download_html.html', { rows }), }; - const [tdoc, rows] = await this.getScoreboard(domainId, tid, true); + const [tdoc, rows] = await contest.getScoreboard(domainId, tid, true, 0); this.binary(await getContent[ext](rows), `${tdoc.title}.${ext}`); } } -class ContestEditHandler extends ContestHandler { +class ContestEditHandler extends Handler { + tdoc: Tdoc; + @param('tid', Types.ObjectID) async prepare(domainId: string, tid: ObjectID) { this.tdoc = await contest.get(domainId, tid); @@ -229,7 +224,7 @@ class ContestEditHandler extends ContestHandler { const endAt = beginAtMoment.clone().add(duration, 'hours').toDate(); if (beginAtMoment.isSameOrAfter(endAt)) throw new ValidationError('duration'); const beginAt = beginAtMoment.toDate(); - pids = await this.verifyProblems(domainId, pids); + pids = await contest.verifyProblems(domainId, pids); await contest.edit(domainId, this.tdoc.docId, { title, content, rule, beginAt, endAt, pids, rated, }); @@ -242,7 +237,13 @@ class ContestEditHandler extends ContestHandler { } } -class ContestProblemHandler extends ContestHandler { +class ContestProblemHandler extends Handler { + tdoc: Tdoc<30 | 60>; + pdoc: Pdoc; + tsdoc: any; + udoc: User; + attended: boolean; + @param('tid', Types.ObjectID) @param('pid', Types.UnsignedInt) async _prepare(domainId: string, tid: ObjectID, pid: number) { @@ -308,7 +309,7 @@ class ContestDetailProblemSubmitHandler extends ContestProblemHandler { async prepare(domainId: string, tid: ObjectID, pid: number) { if (!contest.isOngoing(this.tdoc)) throw new ContestNotLiveError(this.tdoc.docId); let rdocs = []; - if (this.canShowRecord(this.tdoc)) { + if (contest.canShowRecord.call(this, this.tdoc)) { rdocs = await record.getUserInProblemMulti( domainId, this.user._id, this.pdoc.docId, true, @@ -326,7 +327,7 @@ class ContestDetailProblemSubmitHandler extends ContestProblemHandler { tdoc: this.tdoc, pdoc: this.pdoc, udoc: this.udoc, - attended: this.attend, + attended: this.attended, path, rdocs, page_name: 'contest_detail_problem_submit', @@ -349,7 +350,7 @@ class ContestDetailProblemSubmitHandler extends ContestProblemHandler { contest.updateStatus(domainId, this.tdoc.docId, this.user._id, rid, this.pdoc.docId), ]); bus.boardcast('record/change', rdoc); - if (!this.canShowRecord(this.tdoc)) { + if (!contest.canShowRecord.call(this, this.tdoc)) { this.response.body = { tid }; this.response.redirect = this.url('contest_detail', { tid }); } else { @@ -359,7 +360,7 @@ class ContestDetailProblemSubmitHandler extends ContestProblemHandler { } } -class ContestCodeHandler extends ContestHandler { +class ContestCodeHandler extends Handler { @param('tid', Types.ObjectID) async get(domainId: string, tid: ObjectID) { if (!this.user.hasPriv(PRIV.PRIV_READ_RECORD_CODE)) { @@ -386,7 +387,7 @@ class ContestCodeHandler extends ContestHandler { } } -class ContestCreateHandler extends ContestHandler { +class ContestCreateHandler extends Handler { async get() { this.response.template = 'contest_edit.html'; const rules = {}; @@ -431,7 +432,7 @@ class ContestCreateHandler extends ContestHandler { throw new ValidationError('beginAtDate', 'beginAtTime'); } const endAt = beginAt.clone().add(duration, 'hours'); - pids = await this.verifyProblems(domainId, pids); + pids = await contest.verifyProblems(domainId, pids); const tid = await contest.add( domainId, title, content, this.user._id, rule, beginAt.toDate(), endAt.toDate(), pids, rated, @@ -448,7 +449,6 @@ export async function apply() { Route('contest_boardcast', '/contest/:tid/boardcast', ContestBoardcastHandler); Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST); Route('contest_scoreboard', '/contest/:tid/scoreboard', ContestScoreboardHandler, PERM.PERM_VIEW_CONTEST); - Route('contest_scoreboard_raw', '/contest/:tid/scoreboard/raw', ContestScoreboardRawHandler, PERM.PERM_VIEW_CONTEST); Route('contest_scoreboard_download', '/contest/:tid/export/:ext', ContestScoreboardDownloadHandler, PERM.PERM_VIEW_CONTEST); Route('contest_detail_problem', '/contest/:tid/p/:pid', ContestProblemHandler, PERM.PERM_VIEW_CONTEST); Route('contest_detail_problem_file_download', '/contest/:tid/p/:pid/file/:filename', ContestProblemFileDownloadHandler, PERM.PERM_VIEW_PROBLEM); diff --git a/packages/hydrooj/src/handler/homework.ts b/packages/hydrooj/src/handler/homework.ts index 394977dbc..85c3755df 100644 --- a/packages/hydrooj/src/handler/homework.ts +++ b/packages/hydrooj/src/handler/homework.ts @@ -7,7 +7,7 @@ import { ValidationError, HomeworkNotLiveError, ProblemNotFoundError, HomeworkNotAttendedError, } from '../error'; -import { PenaltyRules } from '../interface'; +import { PenaltyRules, Tdoc, Pdoc } from '../interface'; import { Route, Handler, Types, param, } from '../service/server'; @@ -29,9 +29,7 @@ const validatePenaltyRules = (input: string) => { }; const convertPenaltyRules = validatePenaltyRules; -const HomeworkHandler = contest.ContestHandlerMixin(Handler); - -class HomeworkMainHandler extends HomeworkHandler { +class HomeworkMainHandler extends Handler { async get({ domainId }) { const tdocs = await contest.getMulti(domainId, {}, document.TYPE_HOMEWORK).toArray(); const calendar = []; @@ -54,7 +52,7 @@ class HomeworkMainHandler extends HomeworkHandler { } } -class HomeworkDetailHandler extends HomeworkHandler { +class HomeworkDetailHandler extends Handler { @param('tid', Types.ObjectID) @param('page', Types.PositiveInt, true) async get(domainId: string, tid: ObjectID, page = 1) { @@ -72,7 +70,7 @@ class HomeworkDetailHandler extends HomeworkHandler { psdict[pdetail.pid] = pdetail; rdict[pdetail.rid] = { _id: pdetail.rid }; } - if (this.canShowRecord(tdoc) && tsdoc.journal) { + if (contest.canShowRecord.call(this, tdoc) && tsdoc.journal) { rdict = await record.getList( domainId, tsdoc.journal.map((pdetail) => pdetail.rid), @@ -118,7 +116,11 @@ class HomeworkDetailHandler extends HomeworkHandler { } } -class HomeworkDetailProblemHandler extends HomeworkHandler { +class HomeworkDetailProblemHandler extends Handler { + tdoc: Tdoc<60>; + pdoc: Pdoc; + tsdoc: any; + @param('tid', Types.ObjectID) @param('pid', Types.UnsignedInt) async prepare(domainId: string, tid: ObjectID, pid: number) { @@ -162,7 +164,7 @@ class HomeworkDetailProblemSubmitHandler extends HomeworkDetailProblemHandler { if (!attended) throw new HomeworkNotAttendedError(tid); if (!contest.isOngoing(this.tdoc)) throw new HomeworkNotLiveError(tid); if (!this.tdoc.pids.includes(pid)) throw new ProblemNotFoundError(domainId, pid, tid); - const rdocs = this.canShowRecord(this.tdoc) + const rdocs = contest.canShowRecord.call(this, this.tdoc) ? await record.getUserInProblemMulti(domainId, this.user._id, pid, true) .sort('_id', -1).limit(10).toArray() : []; @@ -202,12 +204,12 @@ class HomeworkDetailProblemSubmitHandler extends HomeworkDetailProblemHandler { ]); bus.boardcast('record/change', rdoc); this.response.body = { tid, rid }; - if (this.canShowRecord(this.tdoc)) this.response.redirect = this.url('record_detail', { rid }); + if (contest.canShowRecord.call(this, this.tdoc)) this.response.redirect = this.url('record_detail', { rid }); else this.response.redirect = this.url('homework_detail', { tid }); } } -class HomeworkCreateHandler extends HomeworkHandler { +class HomeworkCreateHandler extends Handler { async get() { const beginAt = moment().add(1, 'day'); const penaltySince = beginAt.clone().add(7, 'days'); @@ -255,7 +257,7 @@ class HomeworkCreateHandler extends HomeworkHandler { const endAt = penaltySince.clone().add(extensionDays, 'days'); if (beginAt.isSameOrAfter(penaltySince)) throw new ValidationError('endAtDate', 'endAtTime'); if (penaltySince.isAfter(endAt)) throw new ValidationError('extensionDays'); - const verified = await this.verifyProblems(domainId, pids); + const verified = await contest.verifyProblems(domainId, pids); const tid = await contest.add(domainId, title, content, this.user._id, 'homework', beginAt.toDate(), endAt.toDate(), verified, rated, { penaltySince: penaltySince.toDate(), penaltyRules }, document.TYPE_HOMEWORK); @@ -264,7 +266,7 @@ class HomeworkCreateHandler extends HomeworkHandler { } } -class HomeworkEditHandler extends HomeworkHandler { +class HomeworkEditHandler extends Handler { @param('tid', Types.ObjectID) async get(domainId: string, tid: ObjectID) { const tdoc = await contest.get(domainId, tid, document.TYPE_HOMEWORK); @@ -316,7 +318,7 @@ class HomeworkEditHandler extends HomeworkHandler { return i; }); const tdoc = await contest.get(domainId, tid, document.TYPE_HOMEWORK); - if (!this.user.own(this.tdoc)) this.checkPerm(PERM.PERM_EDIT_HOMEWORK); + if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_EDIT_HOMEWORK); else this.checkPerm(PERM.PERM_EDIT_HOMEWORK_SELF); let beginAt; let penaltySince; @@ -332,7 +334,7 @@ class HomeworkEditHandler extends HomeworkHandler { } let endAt = penaltySince.clone().add(extensionDays, 'days'); if (beginAt.isSameOrAfter(penaltySince)) throw new ValidationError('endAtDate', 'endAtTime'); - const verified = await this.verifyProblems(domainId, pids); + const verified = await contest.verifyProblems(domainId, pids); beginAt = beginAt.toDate(); endAt = endAt.toDate(); penaltySince = penaltySince.toDate(); @@ -350,11 +352,12 @@ class HomeworkEditHandler extends HomeworkHandler { } } -class HomeworkScoreboardHandler extends HomeworkHandler { +class HomeworkScoreboardHandler extends Handler { @param('tid', Types.ObjectID) - async get(domainId: string, tid: ObjectID) { - const [tdoc, rows, udict] = await this.getScoreboard( - domainId, tid, false, document.TYPE_HOMEWORK, + @param('page', Types.PositiveInt, true) + async get(domainId: string, tid: ObjectID, page = 1) { + const [tdoc, rows, udict] = await contest.getScoreboard( + domainId, tid, false, page, document.TYPE_HOMEWORK, ); const path = [ ['Hydro', 'homepage'], @@ -369,7 +372,7 @@ class HomeworkScoreboardHandler extends HomeworkHandler { } } -class HomeworkScoreboardDownloadHandler extends HomeworkHandler { +class HomeworkScoreboardDownloadHandler extends Handler { @param('tid', Types.ObjectID) @param('ext', Types.Name) async get(domainId: string, tid: ObjectID, ext: string) { @@ -378,12 +381,12 @@ class HomeworkScoreboardDownloadHandler extends HomeworkHandler { html: (rows) => this.renderHTML('contest_scoreboard_download_html.html', { rows }), }; if (!getContent[ext]) throw new ValidationError('ext'); - const [tdoc, rows] = await this.getScoreboard(domainId, tid, true, document.TYPE_HOMEWORK); + const [tdoc, rows] = await contest.getScoreboard(domainId, tid, true, 0, document.TYPE_HOMEWORK); this.binary(await getContent[ext](rows), `${tdoc.title}.${ext}`); } } -class HomeworkCodeHandler extends HomeworkHandler { +class HomeworkCodeHandler extends Handler { @param('tid', Types.ObjectID) async get(domainId: string, tid: ObjectID) { this.checkPerm(PERM.PERM_READ_RECORD_CODE); diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts index 020dda153..1e3176ec7 100644 --- a/packages/hydrooj/src/handler/record.ts +++ b/packages/hydrooj/src/handler/record.ts @@ -14,9 +14,7 @@ import { Route, Handler, Connection, ConnectionHandler, Types, param, } from '../service/server'; -const RecordHandler = contest.ContestHandlerMixin(Handler); - -class RecordListHandler extends RecordHandler { +class RecordListHandler extends Handler { @param('page', Types.PositiveInt, true) @param('pid', Types.Name, true) @param('tid', Types.ObjectID, true) @@ -28,7 +26,7 @@ class RecordListHandler extends RecordHandler { if (tid) { const tdoc = await contest.get(domainId, tid, -1); if (!tdoc) throw new ContestNotFoundError(domainId, pid); - if (!this.canShowScoreboard(tdoc, true)) throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); + if (!contest.canShowScoreboard.call(this, tdoc, true)) throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); } if (uidOrName) { let udoc = await user.getById(domainId, +uidOrName); @@ -55,7 +53,7 @@ class RecordListHandler extends RecordHandler { user.getList(domainId, rdocs.map((rdoc) => rdoc.uid)), canViewProblem ? problem.getList(domainId, rdocs.map((rdoc) => rdoc.pid), canViewProblemHidden, false) - : Object.fromEntries([rdocs.map((rdoc) => [rdoc.pid, problem.create(rdoc.pid, rdoc.pid)])]), + : Object.fromEntries([rdocs.map((rdoc) => [rdoc.pid, problem.create(rdoc.pid, rdoc.pid.toString())])]), ]); const path = [ ['Hydro', 'homepage'], @@ -80,7 +78,7 @@ class RecordListHandler extends RecordHandler { } } -class RecordDetailHandler extends RecordHandler { +class RecordDetailHandler extends Handler { @param('rid', Types.ObjectID) async get(domainId: string, rid: ObjectID) { this.response.template = 'record_detail.html'; @@ -88,7 +86,7 @@ class RecordDetailHandler extends RecordHandler { if (!rdoc) throw new RecordNotFoundError(rid); if (rdoc.contest) { const tdoc = await contest.get(domainId, rdoc.contest.tid, rdoc.contest.type); - if (!this.canShowRecord(tdoc, true)) throw new PermissionError(rid); + if (!contest.canShowRecord.call(this, tdoc, true)) throw new PermissionError(rid); } if (rdoc.uid !== this.user._id && !this.user.hasPerm(PERM.PERM_READ_RECORD_CODE)) rdoc.code = null; // eslint-disable-next-line prefer-const @@ -148,16 +146,15 @@ class RecordDetailHandler extends RecordHandler { } } -const RecordConnectionHandler = contest.ContestHandlerMixin(ConnectionHandler); - -class RecordMainConnectionHandler extends RecordConnectionHandler { +class RecordMainConnectionHandler extends ConnectionHandler { dispose: bus.Disposable; + tid: string; @param('tid', Types.ObjectID, true) async prepare(domainId: string, tid?: ObjectID) { if (tid) { const tdoc = await contest.get(domainId, tid, -1); - if (this.canShowRecord(tdoc)) this.tid = tid.toHexString(); + if (contest.canShowRecord.call(this, tdoc)) this.tid = tid.toHexString(); else throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); } this.dispose = bus.on('record/change', this.onRecordChange.bind(this)); @@ -192,15 +189,16 @@ class RecordMainConnectionHandler extends RecordConnectionHandler { } } -class RecordDetailConnectionHandler extends contest.ContestHandlerMixin(ConnectionHandler) { +class RecordDetailConnectionHandler extends ConnectionHandler { dispose: bus.Disposable; + rid: string; @param('rid', Types.ObjectID) async prepare(domainId: string, rid: ObjectID) { const rdoc = await record.get(domainId, rid); if (rdoc.contest) { const tdoc = await contest.get(domainId, rdoc.contest.tid, -1); - if (!this.canShowRecord(tdoc)) throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); + if (!contest.canShowRecord.call(this, tdoc)) throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); } this.rid = rid.toString(); this.dispose = bus.on('record/change', this.onRecordChange.bind(this)); diff --git a/packages/hydrooj/src/handler/training.ts b/packages/hydrooj/src/handler/training.ts index 2affa3ae9..3f2077770 100644 --- a/packages/hydrooj/src/handler/training.ts +++ b/packages/hydrooj/src/handler/training.ts @@ -84,7 +84,7 @@ class TrainingMainHandler extends Handler { tdict = await training.getList(domainId, Array.from(enrolledTids)); } } - for (const tdoc of tdocs) tdict[tdoc.docId] = tdoc; + for (const tdoc of tdocs) tdict[tdoc.docId.toHexString()] = tdoc; const path = [ ['Hydro', 'homepage'], ['training_main', null], diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 6fff7906c..7cbeb6517 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -1,5 +1,5 @@ import type { Readable, Writable } from 'stream'; -import type { ObjectID } from 'mongodb'; +import type { Cursor, ObjectID } from 'mongodb'; import type fs from 'fs'; import type { Dictionary, NumericDictionary } from 'lodash'; import type * as Koa from 'koa'; @@ -400,17 +400,17 @@ export interface ContestStat extends Record { } export interface ContestRule { - TEXT: string, - check: (args: any) => any, - statusSort: any, - showScoreboard: (tdoc: Tdoc<30 | 60>, now: Date) => boolean, - showRecord: (tdoc: Tdoc<30 | 60>, now: Date) => boolean, - stat: (tdoc: Tdoc<30 | 60>, journal: any[]) => ContestStat, + TEXT: string; + check: (args: any) => any; + statusSort: any; + showScoreboard: (tdoc: Tdoc<30 | 60>, now: Date) => boolean; + showRecord: (tdoc: Tdoc<30 | 60>, now: Date) => boolean; + stat: (tdoc: Tdoc<30 | 60>, journal: any[]) => ContestStat; scoreboard: ( isExport: boolean, _: (s: string) => string, - tdoc: Tdoc<30 | 60>, rankedTsdocs: any[], udict: Udict, pdict: Pdict - ) => ScoreboardRow[], - rank: (tsdocs: any[]) => any[], + tdoc: Tdoc<30 | 60>, pdict: Pdict, cursor: Cursor, page: number, + ) => Promise<[board: ScoreboardRow[], udict: Udict]>; + ranked: (tdoc: Tdoc<30 | 60>, cursor: Cursor) => Promise; } export type ContestRules = Dictionary; diff --git a/packages/hydrooj/src/lib/paginate.ts b/packages/hydrooj/src/lib/paginate.ts index a60ad8d4f..47177cd4d 100644 --- a/packages/hydrooj/src/lib/paginate.ts +++ b/packages/hydrooj/src/lib/paginate.ts @@ -1,9 +1,9 @@ import { Cursor } from 'mongodb'; import { ValidationError } from '../error'; -async function paginate( - cursor: Cursor, page: number, pageSize: number, -): Promise<[any[]/* docs */, number/* numPages */, number/* count */]> { +async function paginate( + cursor: Cursor, page: number, pageSize: number, +): Promise<[docs: T[], numPages: number, count: number]> { if (page <= 0) throw new ValidationError('page'); const [count, pageDocs] = await Promise.all([ cursor.count(), diff --git a/packages/hydrooj/src/lib/rank.ts b/packages/hydrooj/src/lib/rank.ts index 9dfdac374..c5f71cceb 100644 --- a/packages/hydrooj/src/lib/rank.ts +++ b/packages/hydrooj/src/lib/rank.ts @@ -1,16 +1,55 @@ -function ranked(diter: T[], equ = (a: T, b: T) => a === b): [number, T][] { +import { Cursor } from 'mongodb'; +import paginate from './paginate'; + +type G = Promise<[[number, T], () => G] | []>; +interface ranked { + (cursor: Cursor, equ: (a: T, b: T) => boolean, page: number): Promise<[number, T][]>; + iterate(cursor: Cursor, equ: (a: T, b: T) => boolean): G; + all(cursor: Cursor, equ: (a: T, b: T) => boolean): Promise<[number, T][]>; +} + +const ranked: ranked = async function rankedPage(cursor, equ = (a, b) => a === b, page) { let last = null; - let r = 0; - let count = 0; - const results: [number, T][] = []; - for (const doc of diter) { + let r = (page - 1) * 100; + let count = r; + const results = []; + const [docs] = await paginate(cursor, page, 100); + for (const doc of docs) { count++; if (count === 1 || !equ(last, doc)) r = count; last = doc; results.push([r, doc]); } return results; -} +}; +ranked.iterate = async function rankedIterate(cursor: Cursor, equ = (a, b) => a === b) { + let last = null; + let r = 0; + let count = 0; + async function getNext(): G { + if (!await cursor.hasNext()) return []; + const doc = await cursor.next(); + count++; + if (count === 1 || !equ(last, doc)) r = count; + last = doc; + return [[r, doc], getNext]; + } + return await getNext(); +}; +ranked.all = async function rankedAll(cursor, equ = (a, b) => a === b) { + let last = null; + let r = 0; + let count = 0; + const results = []; + const docs = await cursor.toArray(); + for (const doc of docs) { + count++; + if (count === 1 || !equ(last, doc)) r = count; + last = doc; + results.push([r, doc]); + } + return results; +}; global.Hydro.lib.rank = ranked; export = ranked; diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index 746e1121d..54fb7df22 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -8,7 +8,9 @@ import { ContestNotAttendedError, ContestScoreboardHiddenError, } from '../error'; import { - ContestRule, ContestRules, ScoreboardNode, ScoreboardRow, Tdoc, Udict, + ContestRule, ContestRules, Pdict, + ScoreboardNode, ScoreboardRow, Tdoc, + Udict, } from '../interface'; import * as misc from '../lib/misc'; import ranked from '../lib/rank'; @@ -50,7 +52,10 @@ const acm: ContestRule = { } return { accept, time, detail }; }, - scoreboard(isExport, _, tdoc, rankedTsdocs, udict, pdict) { + async scoreboard(isExport, _, tdoc, pdict, cursor, page) { + const rankedTsdocs = await ranked(cursor, (a, b) => a.score === b.score, page); + const uids = rankedTsdocs.map(([, tsdoc]) => tsdoc.uid); + const udict = await user.getList(tdoc.domainId, uids); const columns: ScoreboardRow = [ { type: 'rank', value: _('Rank') }, { type: 'user', value: _('User') }, @@ -93,7 +98,7 @@ const acm: ContestRule = { for (const item of tsdoc.detail || []) tsddict[item.pid] = item; const row: ScoreboardRow = []; row.push( - { type: 'string', value: rank }, + { type: 'string', value: rank.toString() }, { type: 'user', value: udict[tsdoc.uid].uname, raw: tsdoc.uid }, { type: 'string', value: tsdoc.accept || 0 }, ); @@ -134,9 +139,11 @@ const acm: ContestRule = { row.raw = tsdoc; rows.push(row); } - return rows; + return [rows, udict]; + }, + async ranked(tdoc, cursor) { + return await ranked.all(cursor, (a, b) => a.score === b.score); }, - rank: (tsdocs) => ranked(tsdocs, (a, b) => a.score === b.score), }; const oi: ContestRule = { @@ -154,7 +161,10 @@ const oi: ContestRule = { }, showScoreboard: (tdoc, now) => now > tdoc.endAt, showRecord: (tdoc, now) => now > tdoc.endAt, - scoreboard(isExport, _, tdoc, rankedTsdocs, udict, pdict) { + async scoreboard(isExport, _, tdoc, pdict, cursor, page) { + const rankedTsdocs = await ranked(cursor, (a, b) => a.score === b.score, page); + const uids = rankedTsdocs.map(([, tsdoc]) => tsdoc.uid); + const udict = await user.getList(tdoc.domainId, uids); const columns: ScoreboardNode[] = [ { type: 'rank', value: _('Rank') }, { type: 'user', value: _('User') }, @@ -195,9 +205,11 @@ const oi: ContestRule = { } rows.push(row); } - return rows; + return [rows, udict]; + }, + async ranked(tdoc, cursor) { + return await ranked.all(cursor, (a, b) => a.score === b.score); }, - rank: (tsdocs) => ranked(tsdocs, (a, b) => a.score === b.score), }; const ioi: ContestRule = { @@ -255,7 +267,10 @@ const homework: ContestRule = { }, showScoreboard: () => true, showRecord: () => true, - scoreboard(isExport, _, tdoc, rankedTsdocs, udict, pdict) { + async scoreboard(isExport, _, tdoc, pdict, cursor, page) { + const rankedTsdocs = await ranked(cursor, (a, b) => a.score === b.score, page); + const uids = rankedTsdocs.map(([, tsdoc]) => tsdoc.uid); + const udict = await user.getList.call(this, tdoc.domainId, uids); const columns: ScoreboardNode[] = [ { type: 'rank', value: _('Rank') }, { type: 'user', value: _('User') }, @@ -343,9 +358,11 @@ const homework: ContestRule = { } rows.push(row); } - return rows; + return [rows, udict]; + }, + async ranked(tdoc, cursor) { + return await ranked.all(cursor, (a, b) => a.score === b.score); }, - rank: (tsdocs) => ranked(tsdocs, (a, b) => a.score === b.score), }; export const RULES: ContestRules = { @@ -483,62 +500,6 @@ export function isExtended(tdoc: Tdoc) { return tdoc.penaltySince.getTime() <= now && now < tdoc.endAt.getTime(); } -export const ContestHandlerMixin = (c) => class extends c { - canViewHiddenScoreboard() { - return this.user.hasPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); - } - - canShowRecord(tdoc: Tdoc<30 | 60>, allowPermOverride = true) { - if (RULES[tdoc.rule].showRecord(tdoc, new Date())) return true; - if (allowPermOverride && this.canViewHiddenScoreboard()) return true; - return false; - } - - canShowScoreboard(tdoc: Tdoc<30 | 60>, allowPermOverride = true) { - if (RULES[tdoc.rule].showScoreboard(tdoc, new Date())) return true; - if (allowPermOverride && this.canViewHiddenScoreboard()) return true; - return false; - } - - async getScoreboard( - domainId: string, tid: ObjectID, - isExport = false, docType: 30 | 60 = document.TYPE_CONTEST, - ): Promise<[Tdoc<30 | 60>, ScoreboardRow[], Udict]> { - const { - tdoc, udict, pdict, rankedTsdocs, - } = await this.getRawStatus(domainId, tid, docType); - const rows = RULES[tdoc.rule].scoreboard( - isExport, this.translate.bind(this), - tdoc, rankedTsdocs, udict, pdict, - ); - return [tdoc, rows, udict]; - } - - async getRawStatus(domainId: string, tid: ObjectID, docType: 30 | 60 = document.TYPE_CONTEST) { - const tdoc = await get(domainId, tid, docType); - if (!this.canShowScoreboard(tdoc)) throw new ContestScoreboardHiddenError(tid); - if (!this.canShowRecord(tdoc)) throw new ContestScoreboardHiddenError(tid); - const tsdocs = await getMultiStatus(domainId, { docId: tid }, docType).sort(RULES[tdoc.rule].statusSort).toArray(); - const uids = tsdocs.map((tsdoc) => tsdoc.uid); - const [udict, pdict] = await Promise.all([ - user.getList(domainId, uids), - problem.getList(domainId, tdoc.pids, true), - ]); - const rankedTsdocs = RULES[tdoc.rule].rank(tsdocs); - return { - tdoc, tsdocs, udict, pdict, rankedTsdocs, - }; - } - - // eslint-disable-next-line class-methods-use-this - async verifyProblems(domainId: string, pids: Array) { - const pdict = await problem.getList(domainId, pids, true); - const _pids: Set = new Set(); - for (const i in pdict) _pids.add(pdict[i].docId); - return Array.from(_pids); - } -}; - export function setStatus(domainId: string, tid: ObjectID, uid: number, $set: any) { return document.setStatus(domainId, document.TYPE_CONTEST, tid, uid, $set); } @@ -584,6 +545,45 @@ export async function recalcStatus(domainId: string, tid: ObjectID, type: Type = return await Promise.all(tasks); } +export function canViewHiddenScoreboard() { + return this.user.hasPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); +} + +export function canShowRecord(tdoc: Tdoc<30 | 60>, allowPermOverride = true) { + if (RULES[tdoc.rule].showRecord(tdoc, new Date())) return true; + if (allowPermOverride && this.canViewHiddenScoreboard()) return true; + return false; +} + +export function canShowScoreboard(tdoc: Tdoc<30 | 60>, allowPermOverride = true) { + if (RULES[tdoc.rule].showScoreboard(tdoc, new Date())) return true; + if (allowPermOverride && this.canViewHiddenScoreboard()) return true; + return false; +} + +export async function getScoreboard( + domainId: string, tid: ObjectID, + isExport = false, page: number, docType: 30 | 60 = document.TYPE_CONTEST, +): Promise<[Tdoc<30 | 60>, ScoreboardRow[], Udict, Pdict]> { + const tdoc = await get(domainId, tid, docType); + if (!this.canShowScoreboard(tdoc)) throw new ContestScoreboardHiddenError(tid); + if (!this.canShowRecord(tdoc)) throw new ContestScoreboardHiddenError(tid); + const tsdocs = await getMultiStatus(domainId, { docId: tid }, docType).sort(RULES[tdoc.rule].statusSort); + const pdict = await problem.getList(domainId, tdoc.pids, true); + const [rows, udict] = await RULES[tdoc.rule].scoreboard( + isExport, this.translate.bind(this), + tdoc, pdict, tsdocs, page, + ); + return [tdoc, rows, udict, pdict]; +} + +export async function verifyProblems(domainId: string, pids: Array) { + const pdict = await problem.getList(domainId, pids, true); + const _pids: Set = new Set(); + for (const i in pdict) _pids.add(pdict[i].docId); + return Array.from(_pids); +} + export const statusText = (tdoc: Tdoc) => ( isNew(tdoc) ? 'New' @@ -602,7 +602,6 @@ export const getStatusText = (tdoc: Tdoc) => ( global.Hydro.model.contest = { RULES, - ContestHandlerMixin, add, getListStatus, getMultiStatus, @@ -617,6 +616,11 @@ global.Hydro.model.contest = { setStatus, getAndListStatus, recalcStatus, + canShowRecord, + canShowScoreboard, + canViewHiddenScoreboard, + getScoreboard, + verifyProblems, isNew, isUpcoming, isNotStarted, diff --git a/packages/hydrooj/src/model/discussion.ts b/packages/hydrooj/src/model/discussion.ts index c8cb86a6d..7a8c0adb9 100644 --- a/packages/hydrooj/src/model/discussion.ts +++ b/packages/hydrooj/src/model/discussion.ts @@ -94,17 +94,18 @@ export async function add( } // FIXME typings doesn't work -export function get(domainId: string, did: ObjectID, projection: T[] = PROJECTION_PUBLIC as any): Promise> { - return document.get(domainId, document.TYPE_DISCUSSION, did, projection); +export async function get(domainId: string, did: ObjectID, projection: T[] = PROJECTION_PUBLIC as any): Promise> { + return await document.get(domainId, document.TYPE_DISCUSSION, did, projection); } export function edit( domainId: string, did: ObjectID, title: string, content: string, highlight: boolean, pin: boolean, ): Promise { - return document.set(domainId, document.TYPE_DISCUSSION, did, { + const payload = { title, content, highlight, pin, - }); + }; + return document.set(domainId, document.TYPE_DISCUSSION, did, payload); } export function del(domainId: string, did: ObjectID): Promise { diff --git a/packages/hydrooj/src/model/user.ts b/packages/hydrooj/src/model/user.ts index a86183288..b1bb798bb 100644 --- a/packages/hydrooj/src/model/user.ts +++ b/packages/hydrooj/src/model/user.ts @@ -1,4 +1,5 @@ import { Collection } from 'mongodb'; +import LRU from 'lru-cache'; import { escapeRegExp } from 'lodash'; import * as system from './system'; import token from './token'; @@ -16,6 +17,16 @@ import * as bus from '../service/bus'; const coll: Collection = db.collection('user'); const logger = new Logger('model/user'); +const cache = new LRU(500); + +function deleteUserCache(udoc: User | Udoc) { + if (udoc) { + const id = [udoc._id.toString(), udoc.uname.toLowerCase(), udoc.mail.toLowerCase()]; + for (const key of cache.keys().filter((k) => id.includes(k.split('/')[0]))) { + cache.del(key); + } + } +} class User implements _User { _id: number; @@ -125,13 +136,18 @@ class UserModel { @ArgMethod static async getById(domainId: string, _id: number, scope: bigint | string = PERM.PERM_ALL): Promise { + if (cache.has(`${_id}/${domainId}`)) return cache.get(`${_id}/${domainId}`); const udoc = _id === 0 || _id === 1 ? BUILTIN_USERS[_id] : await coll.findOne({ _id }); if (!udoc) return null; const dudoc = await domain.getDomainUser(domainId, udoc); if (typeof scope === 'string') scope = BigInt(scope); - return new UserModel.User(udoc, dudoc, scope).init(); + const res = await new User(udoc, dudoc, scope).init(); + cache.set(`${res._id}/${domainId}`, res); + cache.set(`${res.uname.toLowerCase()}/${domainId}`, res); + cache.set(`${res.mail.toLowerCase()}/${domainId}`, res); + return res; } static async getList(domainId: string, uids: number[]): Promise { @@ -145,6 +161,7 @@ class UserModel { @ArgMethod static async getByUname(domainId: string, uname: string): Promise { const unameLower = uname.trim().toLowerCase(); + if (cache.has(`${unameLower}/${domainId}`)) return cache.get(`${unameLower}/${domainId}`); const udoc = (unameLower === 'guest') ? BUILTIN_USERS[0] : unameLower === 'hydro' @@ -152,12 +169,17 @@ class UserModel { : await coll.findOne({ unameLower }); if (!udoc) return null; const dudoc = await domain.getDomainUser(domainId, udoc); - return new UserModel.User(udoc, dudoc).init(); + const res = await new UserModel.User(udoc, dudoc).init(); + cache.set(`${res._id}/${domainId}`, res); + cache.set(`${res.uname.toLowerCase()}/${domainId}`, res); + cache.set(`${res.mail.toLowerCase()}/${domainId}`, res); + return res; } @ArgMethod static async getByEmail(domainId: string, mail: string): Promise { const mailLower = mail.trim().toLowerCase(); + if (cache.has(`${mailLower}/${domainId}`)) return cache.get(`${mailLower}/${domainId}`); const udoc = (mailLower === 'guest@hydro.local') ? BUILTIN_USERS[0] : mailLower === 'hydro@hydro.local' @@ -165,7 +187,11 @@ class UserModel { : await coll.findOne({ mailLower }); if (!udoc) return null; const dudoc = await domain.getDomainUser(domainId, udoc); - return new UserModel.User(udoc, dudoc).init(); + const res = await new UserModel.User(udoc, dudoc).init(); + cache.set(`${res._id}/${domainId}`, res); + cache.set(`${res.uname.toLowerCase()}/${domainId}`, res); + cache.set(`${res.mail.toLowerCase()}/${domainId}`, res); + return res; } @ArgMethod @@ -173,7 +199,9 @@ class UserModel { const op: any = {}; if ($set && Object.keys($set).length) op.$set = $set; if ($unset && Object.keys($unset).length) op.$unset = $unset; - return await coll.findOneAndUpdate({ _id: uid }, op); + const res = await coll.findOneAndUpdate({ _id: uid }, op, { returnOriginal: false }); + deleteUserCache(res.value); + return res; } @ArgMethod @@ -189,6 +217,7 @@ class UserModel { { $set: { salt, hash: pwhash(password, salt), hashType: 'hydro' } }, { returnOriginal: false }, ); + deleteUserCache(res.value); return res.value; } @@ -198,6 +227,7 @@ class UserModel { if (!udoc) throw new UserNotFoundError(_id); udoc[field] = udoc[field] + n || n; await coll.updateOne({ _id }, { $set: { [field]: udoc[field] } }); + deleteUserCache(udoc); return udoc; } @@ -252,12 +282,13 @@ class UserModel { @ArgMethod static async setPriv(uid: number, priv: number): Promise { - const udoc = await coll.findOneAndUpdate( + const res = await coll.findOneAndUpdate( { _id: uid }, { $set: { priv } }, { returnOriginal: false }, ); - return udoc.value; + deleteUserCache(res.value); + return res.value; } @ArgMethod diff --git a/packages/hydrooj/src/script/rating.ts b/packages/hydrooj/src/script/rating.ts index 26d7653fd..83774e7ed 100644 --- a/packages/hydrooj/src/script/rating.ts +++ b/packages/hydrooj/src/script/rating.ts @@ -42,8 +42,8 @@ async function runProblem(...arg: any[]) { ); const rdict = await record.getList(pdoc.domainId, psdocs.map((psdoc) => psdoc.rid), true); for (const psdoc of psdocs) { - if (rdict[psdoc.rid]) { - const rp = rdict[psdoc.rid].score * p; + if (rdict[psdoc.rid.toHexString()]) { + const rp = rdict[psdoc.rid.toHexString()].score * p; udict[psdoc.uid] = (udict[psdoc.uid] || 1500) + rp; } } @@ -61,10 +61,10 @@ async function runContest(...arg: any[]) { : arg[0]; const udict: ND = (typeof arg[0] === 'string') ? arg[2] : arg[1]; const report = (typeof arg[0] === 'string') ? arg[3] : arg[2]; - const tsdocs = await contest.getMultiStatus(tdoc.domainId, tdoc.docId, tdoc.docType) - .sort(contest.RULES[tdoc.rule].statusSort).toArray(); - if (!tsdocs.length) return; - const rankedTsdocs = contest.RULES[tdoc.rule].rank(tsdocs); + const cursor = await contest.getMultiStatus(tdoc.domainId, tdoc.docId, tdoc.docType) + .sort(contest.RULES[tdoc.rule].statusSort); + if (!await cursor.count()) return; + const rankedTsdocs = await contest.RULES[tdoc.rule].ranked(tdoc, cursor); const users = []; for (const result of rankedTsdocs) { users.push({ uid: result[1].uid, rank: result[0], old: udict[result[1].uid] || 1500 });