试用前准备:全班模拟压测、评分并发 6→16、手动备份与恢复
- test/classroom.ts:45 个虚拟学生走一遍登录、运行、自测、提交,统计每个环节等多久 - 评分并发可配置(SIM_GRADE_CONCURRENCY,默认 16),提交队列同时评的份数跟着走; 2 核实测项目 8 任务 3 全班自测从约 4 分钟降到约 1.5 分钟 - 检查的默认等待 2 秒→5 秒,机器忙时不再误判正确的程序 - 删除学生时删除他的数据库(编号会重新用给新学生) - deploy/backup.sh、deploy/restore.sh:手动备份到宿主机 backups/,恢复前先备份 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e8058376dd
commit
982b1297aa
23 files changed
+479
-25
No files matched your search
+3
-3
@@ -24,7 +24,7 @@ import { languageService } from './language-service'
|
||||
import { getReview, gradebook, manualScore, saveReview, studentDetail, submissionFiles } from './reviews'
|
||||
import type { GradingQueue } from './grading/queue'
|
||||
import { config } from './config'
|
||||
import { restoreExperimentDatabases, userDatabaseDir } from './databases'
|
||||
import { deleteUserDatabases, restoreExperimentDatabases, userDatabaseDir } from './databases'
|
||||
import { listSamples, loadChecks, loadSample } from './samples'
|
||||
import { SqlError, sqlService } from './sql-service'
|
||||
import { serveStatic } from './static'
|
||||
@@ -280,8 +280,8 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
||||
return password ? c.json({ password }) : c.json({ message: '学生不存在' }, 404)
|
||||
})
|
||||
|
||||
app.delete('/api/teacher/students/:id', (c) => {
|
||||
deleteStudent(db, id(c))
|
||||
app.delete('/api/teacher/students/:id', async (c) => {
|
||||
if (deleteStudent(db, id(c))) await deleteUserDatabases(id(c))
|
||||
return c.json({})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// 手动备份:平台数据库(账号、班级、作品、作业、提交、评阅)和每个学生的数据库,导出到一个目录,
|
||||
// 目录结构和 data/ 一样。服务器上用 deploy/backup.sh 备份、deploy/restore.sh 恢复(见 deploy/README.md)。
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { mkdir, readdir, rename, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { config } from './config'
|
||||
|
||||
/**
|
||||
* 把一个 SQLite 数据库完整导出成文件。serialize 在读事务里读出所有页,
|
||||
* 学生程序或平台正在写入时也能得到一份一致的数据库(直接拷文件可能拷到写了一半的状态)
|
||||
*/
|
||||
async function saveCopy(path: string, target: string) {
|
||||
const source = new Database(path, { readonly: true })
|
||||
try {
|
||||
await Bun.write(target, source.serialize())
|
||||
} finally {
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 data/ 里的数据库导出到 target(已有备份时报错,不覆盖) */
|
||||
export async function backupTo(target: string) {
|
||||
if (await Bun.file(join(target, 'iot-sim.db')).exists()) throw new Error(`${target} 里已经有备份`)
|
||||
// 先写到临时目录,写完再改名,不会留下做了一半的备份
|
||||
const temp = `${target}.partial`
|
||||
await rm(temp, { recursive: true, force: true })
|
||||
await mkdir(temp, { recursive: true })
|
||||
|
||||
await saveCopy(join(config.dataDir, 'iot-sim.db'), join(temp, 'iot-sim.db'))
|
||||
const databases = join(config.dataDir, 'databases')
|
||||
for (const user of await readdir(databases).catch(() => [] as string[])) {
|
||||
const files = (await readdir(join(databases, user)).catch(() => [] as string[])).filter((f) => f.endsWith('.db'))
|
||||
if (files.length > 0) await mkdir(join(temp, 'databases', user), { recursive: true })
|
||||
for (const file of files) await saveCopy(join(databases, user, file), join(temp, 'databases', user, file))
|
||||
}
|
||||
await rm(target, { recursive: true, force: true })
|
||||
await rename(temp, target)
|
||||
}
|
||||
@@ -136,6 +136,7 @@ export function resetPassword(db: Database, studentId: number): string | null {
|
||||
}
|
||||
|
||||
/** 删除学生,连同他的作品 */
|
||||
/** 删除学生,返回是否删掉了 */
|
||||
export function deleteStudent(db: Database, studentId: number) {
|
||||
db.query(`DELETE FROM users WHERE id = ? AND role = 'student'`).run(studentId)
|
||||
return db.query(`DELETE FROM users WHERE id = ? AND role = 'student'`).run(studentId).changes > 0
|
||||
}
|
||||
@@ -26,6 +26,12 @@ export const config = {
|
||||
cpuLimitSeconds: Number(env.SIM_CPU_LIMIT_SECONDS ?? 120),
|
||||
/** 全站同时运行的学生程序最多多少个(两个班约 100 人) */
|
||||
maxRuns: Number(env.SIM_MAX_RUNS ?? 150),
|
||||
/**
|
||||
* 全站同时跑多少个评分检查项(提交评分和自测共用)。检查大部分时间在等学生程序的定时器,并发太低时全班排队:
|
||||
* 2 核实测 45 人同时自测项目 8 任务 3,6 个时最后一人等 4 分钟(CPU 才 28%),16 个时 1.5 分钟(CPU 58%);
|
||||
* 24 个时 CPU 接近 80%,定时器来不及触发,参考答案被误判不通过(test/classroom.ts)
|
||||
*/
|
||||
gradeConcurrency: Number(env.SIM_GRADE_CONCURRENCY ?? 16),
|
||||
/** 一次运行最长多少分钟,到时自动结束 */
|
||||
maxRunMinutes: Number(env.SIM_MAX_RUN_MINUTES ?? 60),
|
||||
/** SQLite 数据库所在目录(账号、班级、学生作品);部署时放在数据卷里 */
|
||||
|
||||
@@ -13,6 +13,14 @@ export async function userDatabaseDir(userId: number) {
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户的全部数据库(删除学生时)。用户编号在删掉最大的编号后会被下一个新用户重新使用,
|
||||
* 不删的话新学生会看到前一个人的库
|
||||
*/
|
||||
export async function deleteUserDatabases(userId: number) {
|
||||
await rm(resolve(config.dataDir, 'databases', String(userId)), { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/** 实验预置的数据库脚本(samples/<实验>/database.sql),没有时返回 undefined */
|
||||
export async function experimentScript(sampleId: string): Promise<string | undefined> {
|
||||
const file = Bun.file(join(config.samplesDir, sampleId, 'database.sql'))
|
||||
|
||||
@@ -22,7 +22,8 @@ export type Step =
|
||||
| { kind: 'board'; analog: Partial<Record<AnalogKey, number>>; digital: Partial<Record<DigitalKey, boolean>> }
|
||||
| { kind: 'wait'; ms: number }
|
||||
/** desc:给学生看的期望说明,如"显示当前时间",不写就按期望自动生成 */
|
||||
| { kind: 'expect'; expect: Expectation; within: number; desc?: string }
|
||||
/** timed:规则里写了 within(不通过时说明"几秒内") */
|
||||
| { kind: 'expect'; expect: Expectation; within: number; timed: boolean; desc?: string }
|
||||
|
||||
export type Expectation =
|
||||
/** 控件属性等于某个值:text → Text,ledText → LedText(LED 控件),enabled → Enabled,checked → Checked,visible → Visible(连同所在的容器都可见),on → IsOn(灯泡、报警灯),value → Value(滑动条) */
|
||||
@@ -54,8 +55,11 @@ export type Expectation =
|
||||
| { kind: 'row'; control: string; cells: Record<string, string>; present: boolean }
|
||||
| { kind: 'console'; text: string }
|
||||
|
||||
/** 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within */
|
||||
const DEFAULT_WITHIN = 2000
|
||||
/**
|
||||
* 期望默认最多等多久(毫秒);依赖定时器的检查在规则里写 within。条件一满足就往下走,放宽只让不通过的检查多等一会儿;
|
||||
* 全班同时评分时机器忙,点按钮后查数据库、画图表可能超过 2 秒(test/classroom.ts 实测),所以不能太紧
|
||||
*/
|
||||
const DEFAULT_WITHIN = 5000
|
||||
|
||||
const PROP_KEYS: Record<string, string> = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', visible: 'Visible', on: 'IsOn', value: 'Value' }
|
||||
const STRING_PROPS = new Set(['Text', 'LedText'])
|
||||
@@ -158,6 +162,7 @@ function parseStep(raw: unknown, where: string): Step {
|
||||
kind: 'expect',
|
||||
expect: parseExpectation(expectation, where),
|
||||
within: within === undefined ? DEFAULT_WITHIN : duration(within, where),
|
||||
timed: within !== undefined,
|
||||
...(desc === undefined ? {} : { desc: String(desc) }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 自动评分:编译一次学生工程,每个检查项各启动一次程序,按步骤操作并核对结果。
|
||||
// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;2 核的服务器全站同时跑 GRADE_CONCURRENCY 个检查,其余排队。
|
||||
// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;全站同时跑 config.gradeConcurrency 个检查,其余排队。
|
||||
import type { CheckResult, ControlNode, GradeResult } from '@iot/shared'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
@@ -9,7 +9,6 @@ import { gradingDatabaseDir } from '../databases'
|
||||
import type { Check, Expectation, Step } from './checks'
|
||||
import { ProgramDriver } from './driver'
|
||||
|
||||
const GRADE_CONCURRENCY = 6
|
||||
/** 点数据表格的单元格前,最多等多久让那一行出现(程序可能还在查询数据库、刷新表格) */
|
||||
const GRID_WAIT = 5000
|
||||
/** 操作控件前最多等多久让它出现 */
|
||||
@@ -28,7 +27,7 @@ let running = 0
|
||||
const waiting: (() => void)[] = []
|
||||
|
||||
async function withSlot<T>(task: () => Promise<T>): Promise<T> {
|
||||
if (running >= GRADE_CONCURRENCY) await new Promise<void>((resolve) => waiting.push(resolve))
|
||||
if (running >= config.gradeConcurrency) await new Promise<void>((resolve) => waiting.push(resolve))
|
||||
running++
|
||||
try {
|
||||
return await task()
|
||||
@@ -211,7 +210,7 @@ const quote = (value: unknown) => (value === undefined || value === null || valu
|
||||
|
||||
async function expectStep(driver: ProgramDriver, step: Extract<Step, { kind: 'expect' }>) {
|
||||
const { expect: e, within } = step
|
||||
const wait = within > 2000 ? `${seconds(within)}内` : ''
|
||||
const wait = step.timed ? `${seconds(within)}内` : ''
|
||||
|
||||
if (e.kind === 'unchanged') {
|
||||
const node = need(driver, e.control)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 评分队列:学生提交后立即返回,后台按提交顺序评分(同时最多评 MAX_ACTIVE 份),结果写回数据库。
|
||||
// 评分队列:学生提交后立即返回,后台按提交顺序评分(同时评的份数不超过检查项的并发上限,保证评分槽位能用满),结果写回数据库。
|
||||
// 服务器重启前没评完的提交,启动时继续评。评完调用 onGraded(接着做 AI 评阅)。
|
||||
import type { Database } from 'bun:sqlite'
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
@@ -9,7 +9,6 @@ import { config } from '../config'
|
||||
import { safeJoin } from '../run-session'
|
||||
import { grade } from './grader'
|
||||
|
||||
const MAX_ACTIVE = 3
|
||||
|
||||
/** 把作品写进一个干净的工程目录(评分和自测用) */
|
||||
export async function writeProject(dir: string, files: SourceFile[]) {
|
||||
@@ -44,7 +43,7 @@ export function createGradingQueue(db: Database, onGraded: (submissionId: number
|
||||
}
|
||||
|
||||
function pump() {
|
||||
while (active < MAX_ACTIVE && queue.length > 0) {
|
||||
while (active < config.gradeConcurrency && queue.length > 0) {
|
||||
const id = queue.shift()!
|
||||
active++
|
||||
void gradeOne(id).finally(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 自测:学生提交前自己跑一遍评分,告诉他哪几项不通过、为什么;不计分、不留记录。
|
||||
// 每人同时只能有一次,结果放在内存里,保留到下一次自测(服务器重启后就没有了)。
|
||||
// 检查项和提交评分一样受全站并发上限(grader.ts 的 GRADE_CONCURRENCY)约束,机器忙时排队。
|
||||
// 检查项和提交评分一样受全站并发上限(config.gradeConcurrency)约束,机器忙时排队。
|
||||
import type { SelfCheck, SourceFile } from '@iot/shared'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ClientMessage, ServerMessage } from '@iot/shared'
|
||||
import { createAiQueue } from './ai-review'
|
||||
import { createApp } from './app'
|
||||
import { backupTo } from './backup'
|
||||
import { ensureTeacher, tokenUser } from './auth'
|
||||
import { compileService } from './compile-service'
|
||||
import { languageService } from './language-service'
|
||||
@@ -13,6 +14,15 @@ import { cleanupStaleRuns, RunSession } from './run-session'
|
||||
import { sqlService } from './sql-service'
|
||||
import { checkSandbox, sandboxError } from './sandbox'
|
||||
|
||||
// 手动备份:bun server.js backup <目录>(deploy/backup.sh 调用),导出数据库后退出,不启动服务器
|
||||
if (process.argv[2] === 'backup') {
|
||||
const target = process.argv[3]
|
||||
if (!target) throw new Error('用法:backup <目录>')
|
||||
await backupTo(target)
|
||||
console.log(`已备份到 ${target}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await cleanupStaleRuns()
|
||||
await checkSandbox()
|
||||
if (sandboxError) console.error(sandboxError)
|
||||
|
||||
Reference in new issue
Block a user