Files
lexgo/scripts/bench.py
ila 38d8975325 feat: 自托管交付的安装、备份与恢复工具及部署文档 (#15)
- scripts/ops.py:install-check(依赖与资源版本、客户端不得低于服务端)、init-database(建空库
  并提示最小权限)、backup(全库 dump + manifest,不含凭据)、restore(默认只写空库、
  --confirm 必需、覆盖需 --force、拒绝系统库与带 CREATE DATABASE/USE 的 dump、比对源库校验和)、
  verify(完整性 + 可选两账号接口闭环)、smoke(干净实例上建两个演练账号走通学习闭环)
- scripts/bench.py:写明规模的人造数据集性能测量,记录数据量、机器与 p50/p95
- tests/test_lexgo_ops.py:版本解析、库名白名单、dump 安全性、manifest 字段白名单等无库测试
- Wiki 新增 Deployment-and-Operations 页面与 wiki-docs.json 映射(含升级回滚与已知限制)
- 更新 Architecture、Business-Rules、Local-Development、Product-Requirements、Home 与
  README/AGENTS;本单无 schema 与接口变化
2026-09-15 16:30:23 +08:00

208 lines
10 KiB
Python

"""Measures the LexGo API on a stated synthetic dataset.
The numbers this prints describe one machine and one dataset; they are an observation, not a
capacity promise. Everything runs against a database whose name contains ``lexgo`` and that the
operator creates for the run, so no real instance is touched.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import platform
import statistics
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
import ops # noqa: E402
WORDS = ("curiosity", "door", "step", "reading", "language", "garden", "window", "music", "river",
"stone", "letter", "morning", "shadow", "bridge", "silence", "travel", "kitchen", "paper")
def machine_facts() -> dict:
facts = {
"os": platform.platform(),
"cpu_count": os.cpu_count(),
"python": platform.python_version(),
}
try:
import shutil
if os.name == "nt":
output = subprocess.run(["wmic", "computersystem", "get", "TotalPhysicalMemory"],
capture_output=True, text=True, encoding="utf-8", errors="replace").stdout
numbers = [line.strip() for line in output.splitlines() if line.strip().isdigit()]
if numbers:
facts["memory_gb"] = round(int(numbers[0]) / (1024 ** 3), 1)
else:
with open("/proc/meminfo") as handle:
for line in handle:
if line.startswith("MemTotal"):
facts["memory_gb"] = round(int(line.split()[1]) / (1024 ** 2), 1)
break
facts["go"] = subprocess.run(["go", "version"], capture_output=True, text=True,
encoding="utf-8", errors="replace").stdout.strip()
del shutil
except Exception:
pass
return facts
def seed(database: str, chapters: int, chapter_words: int, terms: int, reviews: int) -> dict:
"""Fills a fresh benchmark database with a synthetic learner, book, terms and schedules.
The account itself is left alone: it was created through the API beforehand, so its password
hash is a real one and the benchmark measures the same login path a user takes.
"""
for table in ("lexgo_review_answers", "lexgo_term_reviews", "lexgo_terms", "lexgo_chapters",
"lexgo_books", "lexgo_spaces", "lexgo_sessions"):
ops.query(database, "DELETE FROM " + table)
owner = int(ops.scalar(database, "SELECT user_id FROM sys_user ORDER BY user_id LIMIT 1", "0") or 0)
if not owner:
raise SystemExit("先创建一个账号再压测(例如 smoke 或 bootstrap)。")
ops.query(database, "INSERT INTO lexgo_spaces (owner_id, language) VALUES (%d, 'en')" % owner)
stamp = "2026-01-01 00:00:00.000"
body = " ".join(WORDS[index % len(WORDS)] for index in range(chapter_words))
for chapter in range(1, chapters + 1):
text = "%s chapter %d. " % (body, chapter)
sha = hashlib.sha256(text.encode("utf-8")).hexdigest()
ops.query(database, "INSERT INTO lexgo_books (id, owner_id, title, language, created_at, updated_at) "
"VALUES (%d, %d, 'Bench book %d', 'en', '%s', '%s')" % (chapter, owner, chapter, stamp, stamp))
ops.query(database, "INSERT INTO lexgo_chapters (id, book_id, owner_id, ordinal, title, original_text, char_count, "
"content_sha256, status, created_at, updated_at) VALUES (%d, %d, %d, 1, 'Bench %d', '%s', %d, "
"'%s', 'ready', '%s', '%s')" % (chapter, chapter, owner, chapter, text.replace("'", "''"), len(text), sha, stamp, stamp))
rows = []
for term_id in range(1, terms + 1):
word = "%s%d" % (WORDS[term_id % len(WORDS)], term_id)
rows.append("(%d, %d, 'en', '%s', '%s', '虚构释义', '', 'new', 0, '%s', '%s')" % (term_id, owner, word, word, stamp, stamp))
if len(rows) == 500:
ops.query(database, "INSERT INTO lexgo_terms (id, owner_id, language, term, original_form, definition, examples, "
"status, level, created_at, updated_at) VALUES " + ",".join(rows))
rows = []
if rows:
ops.query(database, "INSERT INTO lexgo_terms (id, owner_id, language, term, original_form, definition, examples, "
"status, level, created_at, updated_at) VALUES " + ",".join(rows))
rows = []
for term_id in range(1, terms + 1):
rows.append("(%d, %d, 'en', DATE_ADD('%s', INTERVAL %d SECOND), 0, 0, 0)" % (term_id, owner, stamp, term_id % 900000))
if len(rows) == 500:
ops.query(database, "INSERT INTO lexgo_term_reviews (term_id, owner_id, language, due_at, review_count, "
"correct_count, wrong_count) VALUES " + ",".join(rows))
rows = []
if rows:
ops.query(database, "INSERT INTO lexgo_term_reviews (term_id, owner_id, language, due_at, review_count, "
"correct_count, wrong_count) VALUES " + ",".join(rows))
if reviews:
rows = []
for index in range(1, reviews + 1):
rows.append("(%d, %d, 'b%063d', %d, 'correct', 'applied', 'new', 'learning', 0, 1, '%s', '%s', 0, '%s')"
% (index, owner, index, (index % terms) + 1, stamp, stamp, stamp))
if len(rows) == 500:
ops.query(database, "INSERT INTO lexgo_review_answers (id, owner_id, answer_key, term_id, grade, result, "
"status_before, status_after, level_before, level_after, due_at_before, due_at_after, "
"requeued, created_at) VALUES " + ",".join(rows))
rows = []
if rows:
ops.query(database, "INSERT INTO lexgo_review_answers (id, owner_id, answer_key, term_id, grade, result, "
"status_before, status_after, level_before, level_after, due_at_before, due_at_after, "
"requeued, created_at) VALUES " + ",".join(rows))
return {
"owner_id": owner,
"chapters": chapters,
"words_per_chapter": chapter_words,
"terms": terms,
"review_answers": reviews,
"row_counts": ops.row_counts(database),
}
def measure(label: str, call, repeats: int) -> dict:
timings = []
errors = 0
for index in range(repeats):
started = time.perf_counter()
status, _ = call(index)
elapsed = (time.perf_counter() - started) * 1000
if status >= 400:
errors += 1
timings.append(elapsed)
timings.sort()
return {
"endpoint": label,
"requests": repeats,
"errors": errors,
"p50_ms": round(statistics.median(timings), 1),
"p95_ms": round(timings[min(len(timings) - 1, int(len(timings) * 0.95))], 1),
"max_ms": round(timings[-1], 1),
}
def main() -> int:
parser = argparse.ArgumentParser(description="在写明规模的人造数据集上测量接口耗时")
parser.add_argument("--database", default="lexgo_bench")
parser.add_argument("--api", default="http://127.0.0.1:8012")
parser.add_argument("--user", default="bench_admin", help="压测账号")
parser.add_argument("--chapters", type=int, default=20)
parser.add_argument("--chapter-words", type=int, default=500)
parser.add_argument("--terms", type=int, default=2000)
parser.add_argument("--reviews", type=int, default=8000)
parser.add_argument("--repeats", type=int, default=30)
parser.add_argument("--out", default=str(ROOT / ".local" / "issue15-bench.json"))
args = parser.parse_args()
ops.load_env()
database = ops.validate_database_name(args.database, "压测")
dataset = seed(database, args.chapters, args.chapter_words, args.terms, args.reviews)
print("数据集:" + json.dumps(dataset["row_counts"], ensure_ascii=False))
client = ops.Api(args.api)
password = os.environ.get("LEXGO_BENCH_PASSWORD", os.environ.get("LEXGO_TRIAL_PASSWORD", ""))
status, token = client.login(args.user, password)
if status != 200 or not token:
raise SystemExit(args.user + " 无法登录:请先在演练库上建号并设置 LEXGO_BENCH_PASSWORD。")
# The login endpoint allows 30 attempts per minute per address, so a sustained loop would
# measure the limiter instead of the login path. A short loop keeps the number meaningful.
login_repeats = max(1, min(args.repeats, 10))
results = [measure("POST /login", lambda _: client.login(args.user, password), login_repeats)]
results.append(measure("GET /books", lambda _: client.call("GET", "books", token), args.repeats))
results.append(measure("GET /chapters/1", lambda _: client.call("GET", "chapters/1", token), args.repeats))
results.append(measure("GET /chapters/1/tokens", lambda _: client.call("GET", "chapters/1/tokens", token), args.repeats))
results.append(measure("GET /terms (page 1)", lambda _: client.call("GET", "terms?page=1&limit=20", token), args.repeats))
results.append(measure("GET /terms?query (search)", lambda _: client.call("GET", "terms?query=curiosity&limit=20", token), args.repeats))
results.append(measure("GET /progress", lambda _: client.call("GET", "progress", token), args.repeats))
results.append(measure("GET /reviews/queue", lambda _: client.call("GET", "reviews/queue", token), args.repeats))
report = {
"measured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"api": args.api,
"database": database,
"dataset": dataset,
"machine": machine_facts(),
"mysql_server": ops.scalar("", "SELECT VERSION()", ""),
"results": results,
"note": "单机、单进程、无并发压力;数字是观察值,不是容量承诺。登录接口每分钟每地址限 30 次,"
"因此登录只测 10 次,且该数字不代表登录吞吐。",
}
Path(args.out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
for row in results:
print(" %-28s p50 %7.1f ms p95 %7.1f ms max %7.1f ms 错误 %d" %
(row["endpoint"], row["p50_ms"], row["p95_ms"], row["max_ms"], row["errors"]))
print("报告:" + args.out)
return 0
if __name__ == "__main__":
sys.exit(main())