feat(submissions): bind final performance to trusted measurement epochs
This commit is contained in:
@@ -0,0 +1,119 @@
|
|||||||
|
"""Trusted final performance projection; no re-encoding or promotion of old reports."""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0003_submission_performance"
|
||||||
|
down_revision = "0002_multi_runtime"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("judge_attempts", sa.Column("measurement_epoch", sa.String(64), nullable=True))
|
||||||
|
op.create_check_constraint(
|
||||||
|
op.f("ck_judge_attempts_measurement_epoch"),
|
||||||
|
"judge_attempts",
|
||||||
|
"measurement_epoch IS NULL OR measurement_epoch ~ '^[0-9a-f]{64}$'",
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"evaluation_performance",
|
||||||
|
sa.Column("evaluation_id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("submission_id", sa.Uuid(), nullable=False, unique=True),
|
||||||
|
sa.Column("attempt_id", sa.Uuid(), nullable=False, unique=True),
|
||||||
|
sa.Column("problem_version_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("language", sa.String(16), nullable=False),
|
||||||
|
sa.Column("comparison_group", sa.String(64), nullable=False),
|
||||||
|
sa.Column("measurement_epoch", sa.String(64), nullable=False),
|
||||||
|
sa.Column("report_digest", sa.String(64), nullable=False),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("cpu_time_us", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("memory_peak_bytes", sa.BigInteger(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["evaluation_id", "problem_version_id"],
|
||||||
|
["evaluations.id", "evaluations.problem_version_id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["submission_id", "problem_version_id"],
|
||||||
|
["submissions.id", "submissions.problem_version_id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["attempt_id", "evaluation_id"], ["judge_attempts.id", "judge_attempts.evaluation_id"]
|
||||||
|
),
|
||||||
|
sa.CheckConstraint("language IN ('c17','cpp20','python313','java21')", name="language"),
|
||||||
|
sa.CheckConstraint("comparison_group ~ '^[0-9a-f]{64}$'", name="comparison_group"),
|
||||||
|
sa.CheckConstraint("measurement_epoch ~ '^[0-9a-f]{64}$'", name="measurement_epoch"),
|
||||||
|
sa.CheckConstraint("report_digest ~ '^[0-9a-f]{64}$'", name="report_digest"),
|
||||||
|
sa.CheckConstraint("cpu_time_us IS NULL OR cpu_time_us >= 0", name="cpu"),
|
||||||
|
sa.CheckConstraint("memory_peak_bytes IS NULL OR memory_peak_bytes >= 0", name="memory"),
|
||||||
|
)
|
||||||
|
for metric in ("cpu_time_us", "memory_peak_bytes"):
|
||||||
|
op.create_index(
|
||||||
|
f"ix_performance_{metric}_cohort",
|
||||||
|
"evaluation_performance",
|
||||||
|
["comparison_group", sa.text("finished_at DESC"), sa.text("evaluation_id DESC")],
|
||||||
|
postgresql_where=sa.text(f"{metric} IS NOT NULL"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_submissions_owned_problem_history",
|
||||||
|
"submissions",
|
||||||
|
["account_id", "problem_version_id", sa.text("created_at DESC"), sa.text("id DESC")],
|
||||||
|
)
|
||||||
|
op.execute("""
|
||||||
|
CREATE FUNCTION public.kf_trusted_performance() RETURNS trigger LANGUAGE plpgsql
|
||||||
|
SET search_path = pg_catalog, public AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP != 'INSERT' THEN
|
||||||
|
RAISE EXCEPTION 'final performance is immutable' USING ERRCODE = '23514';
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.evaluations e
|
||||||
|
JOIN public.judge_attempts a ON a.id=e.current_attempt_id AND a.evaluation_id=e.id
|
||||||
|
JOIN public.submissions s ON s.id=e.submission_id
|
||||||
|
WHERE e.id=NEW.evaluation_id AND e.state='finished' AND e.verdict='accepted'
|
||||||
|
AND e.failure_code IS NULL AND a.cleanup_complete IS TRUE
|
||||||
|
AND e.current_attempt_id=NEW.attempt_id AND s.id=NEW.submission_id
|
||||||
|
AND s.problem_version_id=NEW.problem_version_id AND s.language=NEW.language
|
||||||
|
AND a.measurement_epoch=NEW.measurement_epoch
|
||||||
|
AND e.final_report_digest=NEW.report_digest AND a.report_digest=NEW.report_digest
|
||||||
|
AND e.updated_at=NEW.finished_at
|
||||||
|
AND e.final_report->>'cleanup_complete'='true'
|
||||||
|
AND (e.final_report->>'run_cpu_time_us')::bigint
|
||||||
|
IS NOT DISTINCT FROM NEW.cpu_time_us
|
||||||
|
AND (e.final_report->>'run_memory_peak_bytes')::bigint
|
||||||
|
IS NOT DISTINCT FROM NEW.memory_peak_bytes
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'performance requires a bound trusted final AC'
|
||||||
|
USING ERRCODE = '23514';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END $$
|
||||||
|
""")
|
||||||
|
op.execute("REVOKE ALL ON FUNCTION public.kf_trusted_performance() FROM PUBLIC")
|
||||||
|
op.execute("""
|
||||||
|
CREATE TRIGGER kf_trusted_performance BEFORE INSERT OR UPDATE OR DELETE
|
||||||
|
ON public.evaluation_performance FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.kf_trusted_performance()
|
||||||
|
""")
|
||||||
|
op.execute("REVOKE ALL ON evaluation_performance FROM PUBLIC,kf_oj_platform,kf_oj_coordinator")
|
||||||
|
op.execute("GRANT SELECT ON evaluation_performance TO kf_oj_platform,kf_oj_coordinator")
|
||||||
|
op.execute("GRANT INSERT ON evaluation_performance TO kf_oj_coordinator")
|
||||||
|
# Existing attempt INSERT permission covers the new column. No UPDATE epoch permission:
|
||||||
|
# an unbound historical attempt must never be promoted by changing current configuration.
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS(SELECT 1 FROM evaluation_performance) OR
|
||||||
|
EXISTS(SELECT 1 FROM judge_attempts WHERE measurement_epoch IS NOT NULL) THEN
|
||||||
|
RAISE EXCEPTION 'cannot discard trusted measurement bindings'
|
||||||
|
USING ERRCODE = '23514';
|
||||||
|
END IF;
|
||||||
|
END $$
|
||||||
|
""")
|
||||||
|
op.drop_table("evaluation_performance")
|
||||||
|
op.execute("DROP FUNCTION public.kf_trusted_performance()")
|
||||||
|
op.drop_index("ix_submissions_owned_problem_history", table_name="submissions")
|
||||||
|
op.drop_constraint(op.f("ck_judge_attempts_measurement_epoch"), "judge_attempts", type_="check")
|
||||||
|
op.drop_column("judge_attempts", "measurement_epoch")
|
||||||
@@ -15,6 +15,7 @@ from sqlalchemy import (
|
|||||||
String,
|
String,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
func,
|
func,
|
||||||
|
text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
@@ -38,6 +39,13 @@ class Submission(CreatedAt, Base):
|
|||||||
["source_digest", "artifact_state"], ["artifacts.digest", "artifacts.state"]
|
["source_digest", "artifact_state"], ["artifacts.digest", "artifacts.state"]
|
||||||
),
|
),
|
||||||
Index("ix_submissions_account_history", "account_id", "created_at", "id"),
|
Index("ix_submissions_account_history", "account_id", "created_at", "id"),
|
||||||
|
Index(
|
||||||
|
"ix_submissions_owned_problem_history",
|
||||||
|
"account_id",
|
||||||
|
"problem_version_id",
|
||||||
|
text("created_at DESC"),
|
||||||
|
text("id DESC"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[UUID] = mapped_column(primary_key=True)
|
id: Mapped[UUID] = mapped_column(primary_key=True)
|
||||||
@@ -112,6 +120,10 @@ class JudgeAttempt(CreatedAt, Base):
|
|||||||
CheckConstraint("fencing_version > 0", name="fence"),
|
CheckConstraint("fencing_version > 0", name="fence"),
|
||||||
CheckConstraint("lease_token_hash ~ '^[0-9a-f]{64}$'", name="token_digest"),
|
CheckConstraint("lease_token_hash ~ '^[0-9a-f]{64}$'", name="token_digest"),
|
||||||
CheckConstraint("manifest_digest ~ '^[0-9a-f]{64}$'", name="manifest_digest"),
|
CheckConstraint("manifest_digest ~ '^[0-9a-f]{64}$'", name="manifest_digest"),
|
||||||
|
CheckConstraint(
|
||||||
|
"measurement_epoch IS NULL OR measurement_epoch ~ '^[0-9a-f]{64}$'",
|
||||||
|
name="measurement_epoch",
|
||||||
|
),
|
||||||
CheckConstraint("sequence >= 0", name="sequence"),
|
CheckConstraint("sequence >= 0", name="sequence"),
|
||||||
Index("ix_judge_attempts_expiry", "expires_at", "evaluation_id"),
|
Index("ix_judge_attempts_expiry", "expires_at", "evaluation_id"),
|
||||||
)
|
)
|
||||||
@@ -132,6 +144,58 @@ class JudgeAttempt(CreatedAt, Base):
|
|||||||
report: Mapped[dict | None] = mapped_column(JSONB)
|
report: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
report_digest: Mapped[str | None] = mapped_column(String(64))
|
report_digest: Mapped[str | None] = mapped_column(String(64))
|
||||||
cleanup_complete: Mapped[bool | None] = mapped_column(Boolean)
|
cleanup_complete: Mapped[bool | None] = mapped_column(Boolean)
|
||||||
|
measurement_epoch: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|
||||||
|
|
||||||
|
class EvaluationPerformance(Base):
|
||||||
|
"""Coordinator-only immutable final projection; the platform only reads it."""
|
||||||
|
|
||||||
|
__tablename__ = "evaluation_performance"
|
||||||
|
__table_args__ = (
|
||||||
|
ForeignKeyConstraint(
|
||||||
|
["evaluation_id", "problem_version_id"],
|
||||||
|
["evaluations.id", "evaluations.problem_version_id"],
|
||||||
|
),
|
||||||
|
ForeignKeyConstraint(
|
||||||
|
["submission_id", "problem_version_id"],
|
||||||
|
["submissions.id", "submissions.problem_version_id"],
|
||||||
|
),
|
||||||
|
ForeignKeyConstraint(
|
||||||
|
["attempt_id", "evaluation_id"], ["judge_attempts.id", "judge_attempts.evaluation_id"]
|
||||||
|
),
|
||||||
|
CheckConstraint("language IN ('c17','cpp20','python313','java21')", name="language"),
|
||||||
|
CheckConstraint("comparison_group ~ '^[0-9a-f]{64}$'", name="comparison_group"),
|
||||||
|
CheckConstraint("measurement_epoch ~ '^[0-9a-f]{64}$'", name="measurement_epoch"),
|
||||||
|
CheckConstraint("report_digest ~ '^[0-9a-f]{64}$'", name="report_digest"),
|
||||||
|
CheckConstraint("cpu_time_us IS NULL OR cpu_time_us >= 0", name="cpu"),
|
||||||
|
CheckConstraint("memory_peak_bytes IS NULL OR memory_peak_bytes >= 0", name="memory"),
|
||||||
|
Index(
|
||||||
|
"ix_performance_cpu_time_us_cohort",
|
||||||
|
"comparison_group",
|
||||||
|
text("finished_at DESC"),
|
||||||
|
text("evaluation_id DESC"),
|
||||||
|
postgresql_where=text("cpu_time_us IS NOT NULL"),
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_performance_memory_peak_bytes_cohort",
|
||||||
|
"comparison_group",
|
||||||
|
text("finished_at DESC"),
|
||||||
|
text("evaluation_id DESC"),
|
||||||
|
postgresql_where=text("memory_peak_bytes IS NOT NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
evaluation_id: Mapped[UUID] = mapped_column(primary_key=True)
|
||||||
|
submission_id: Mapped[UUID] = mapped_column(unique=True)
|
||||||
|
attempt_id: Mapped[UUID] = mapped_column(unique=True)
|
||||||
|
problem_version_id: Mapped[UUID]
|
||||||
|
language: Mapped[str] = mapped_column(String(16))
|
||||||
|
comparison_group: Mapped[str] = mapped_column(String(64))
|
||||||
|
measurement_epoch: Mapped[str] = mapped_column(String(64))
|
||||||
|
report_digest: Mapped[str] = mapped_column(String(64))
|
||||||
|
finished_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
cpu_time_us: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
memory_peak_bytes: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
class CaseResult(Base):
|
class CaseResult(Base):
|
||||||
|
|||||||
@@ -28,7 +28,13 @@ ROOT = Path(__file__).resolve().parents[2]
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def fixture(
|
async def fixture(
|
||||||
postgres_url, tmp_path, monkeypatch, *, import_builder=import_fixture, node_policy=None
|
postgres_url,
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
*,
|
||||||
|
import_builder=import_fixture,
|
||||||
|
node_policy=None,
|
||||||
|
measurement_epochs=None,
|
||||||
):
|
):
|
||||||
_, policy = import_builder(tmp_path / "import")
|
_, policy = import_builder(tmp_path / "import")
|
||||||
engine = create_async_engine(postgres_url)
|
engine = create_async_engine(postgres_url)
|
||||||
@@ -59,6 +65,9 @@ async def fixture(
|
|||||||
reserved.bind(("127.0.0.1", 0))
|
reserved.bind(("127.0.0.1", 0))
|
||||||
port = reserved.getsockname()[1]
|
port = reserved.getsockname()[1]
|
||||||
workers = [dict(id=str(uuid4()), token=secrets.token_hex(32)) for _ in range(2)]
|
workers = [dict(id=str(uuid4()), token=secrets.token_hex(32)) for _ in range(2)]
|
||||||
|
if measurement_epochs is not None:
|
||||||
|
for worker, epoch in zip(workers, measurement_epochs, strict=True):
|
||||||
|
worker["measurement_epoch"] = epoch
|
||||||
platform = secrets.token_hex(32)
|
platform = secrets.token_hex(32)
|
||||||
config = tmp_path / "coordinator.json"
|
config = tmp_path / "coordinator.json"
|
||||||
config.write_text(
|
config.write_text(
|
||||||
@@ -152,6 +161,7 @@ async def fixture(
|
|||||||
client.fixture_version = version
|
client.fixture_version = version
|
||||||
client.coordinator_unit = unit
|
client.coordinator_unit = unit
|
||||||
client.coordinator_role = role
|
client.coordinator_role = role
|
||||||
|
client.coordinator_config = config
|
||||||
yield client, c, workers, platform, create
|
yield client, c, workers, platform, create
|
||||||
finally:
|
finally:
|
||||||
subprocess.run(["systemctl", "--user", "stop", unit], check=True, capture_output=True)
|
subprocess.run(["systemctl", "--user", "stop", unit], check=True, capture_output=True)
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Finite actual control-plane PG/HTTP tests; all reports explicitly synthetic.
|
||||||
|
|
||||||
|
No user program runs here. Privilege/tamper/fault acceptance runs in the VM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from test_coordinator import (
|
||||||
|
ROOT,
|
||||||
|
claim,
|
||||||
|
fixture,
|
||||||
|
manifest,
|
||||||
|
stages,
|
||||||
|
synthetic_report,
|
||||||
|
worker_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_final_projection_atomic_idempotent_and_bound(postgres_url, tmp_path, monkeypatch):
|
||||||
|
async def run():
|
||||||
|
async with fixture(
|
||||||
|
postgres_url, tmp_path, monkeypatch, measurement_epochs=["a" * 64, "b" * 64]
|
||||||
|
) as (client, c, workers, _, create):
|
||||||
|
groups = []
|
||||||
|
for index in [0, 0, 1]:
|
||||||
|
created = await create()
|
||||||
|
worker = workers[index]
|
||||||
|
grant = await claim(client, created, worker)
|
||||||
|
await stages(client, worker, grant)
|
||||||
|
report = synthetic_report(grant, await manifest(c, grant))
|
||||||
|
result = await client.post(
|
||||||
|
"/internal/report", json=report, headers=worker_headers(worker, grant)
|
||||||
|
)
|
||||||
|
assert result.status_code == 204, result.text
|
||||||
|
row = await c.fetchrow(
|
||||||
|
"SELECT p.*,e.final_report_digest,a.report_digest AS attempt_digest "
|
||||||
|
"FROM evaluation_performance p JOIN evaluations e ON e.id=p.evaluation_id "
|
||||||
|
"JOIN judge_attempts a ON a.id=p.attempt_id WHERE p.evaluation_id=$1",
|
||||||
|
created.evaluation_id,
|
||||||
|
)
|
||||||
|
assert row is not None
|
||||||
|
assert row["cpu_time_us"] == report["run_cpu_time_us"]
|
||||||
|
assert row["memory_peak_bytes"] == report["run_memory_peak_bytes"]
|
||||||
|
assert row["measurement_epoch"] == worker["measurement_epoch"]
|
||||||
|
assert row["report_digest"] == row["final_report_digest"] == row["attempt_digest"]
|
||||||
|
groups.append(row["comparison_group"])
|
||||||
|
repeated = await client.post(
|
||||||
|
"/internal/report", json=report, headers=worker_headers(worker, grant)
|
||||||
|
)
|
||||||
|
assert repeated.status_code == 204
|
||||||
|
assert (
|
||||||
|
await c.fetchval(
|
||||||
|
"SELECT count(*) FROM evaluation_performance WHERE evaluation_id=$1",
|
||||||
|
created.evaluation_id,
|
||||||
|
)
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
backfill = await asyncio.to_thread(
|
||||||
|
subprocess.run,
|
||||||
|
[
|
||||||
|
str(ROOT / "judge/target/debug/kf-coordinator"),
|
||||||
|
"--config",
|
||||||
|
str(client.coordinator_config),
|
||||||
|
"--backfill-performance",
|
||||||
|
str(created.evaluation_id),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
assert backfill.returncode == 0, backfill.stderr
|
||||||
|
assert backfill.stdout.strip() == "trusted performance rows created: 0"
|
||||||
|
assert groups[0] == groups[1] and groups[2] != groups[0]
|
||||||
|
assert await c.fetchval("SELECT count(*) FROM evaluation_performance") == 3
|
||||||
|
# Read grants rather than attempting denied writes on this host.
|
||||||
|
grants = await c.fetchrow(
|
||||||
|
"SELECT has_table_privilege('kf_oj_platform','evaluation_performance','SELECT') "
|
||||||
|
"AS read,has_table_privilege('kf_oj_platform','evaluation_performance','INSERT') "
|
||||||
|
"AS platform_insert,has_table_privilege('kf_oj_coordinator',"
|
||||||
|
"'evaluation_performance','INSERT') AS coord_insert,has_table_privilege("
|
||||||
|
"'kf_oj_coordinator','evaluation_performance','UPDATE') AS coord_update,"
|
||||||
|
"has_column_privilege('kf_oj_coordinator','judge_attempts',"
|
||||||
|
"'measurement_epoch','UPDATE') AS epoch_update"
|
||||||
|
)
|
||||||
|
assert dict(grants) == dict(
|
||||||
|
read=True,
|
||||||
|
platform_insert=False,
|
||||||
|
coord_insert=True,
|
||||||
|
coord_update=False,
|
||||||
|
epoch_update=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbound_legacy_and_nonaccepted_stay_out(postgres_url, tmp_path, monkeypatch):
|
||||||
|
async def run():
|
||||||
|
async with fixture(postgres_url, tmp_path, monkeypatch) as (client, c, workers, _, create):
|
||||||
|
for verdict in ["accepted", "wrong_answer"]:
|
||||||
|
created = await create()
|
||||||
|
grant = await claim(client, created, workers[0])
|
||||||
|
await stages(client, workers[0], grant)
|
||||||
|
report = synthetic_report(grant, await manifest(c, grant), verdict)
|
||||||
|
result = await client.post(
|
||||||
|
"/internal/report", json=report, headers=worker_headers(workers[0], grant)
|
||||||
|
)
|
||||||
|
assert result.status_code == 204, result.text
|
||||||
|
assert await c.fetchval(
|
||||||
|
"SELECT final_report_digest IS NOT NULL FROM evaluations WHERE id=$1",
|
||||||
|
created.evaluation_id,
|
||||||
|
)
|
||||||
|
assert await c.fetchval("SELECT count(*) FROM evaluation_performance") == 0
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
@@ -19,3 +19,15 @@
|
|||||||
清理失败保留 `cleanup_failed`,只有已确认停止的基础设施失败可按有限预算重试。
|
清理失败保留 `cleanup_failed`,只有已确认停止的基础设施失败可按有限预算重试。
|
||||||
恢复扫描每批最多16项,待领取事件投递30秒后仍无执行权时补偿重新通知。
|
恢复扫描每批最多16项,待领取事件投递30秒后仍无执行权时补偿重新通知。
|
||||||
Worker 的实际停止证明与执行隔离需独立 VM 验收;普通数据库测试不代表 Linux 安全验收。
|
Worker 的实际停止证明与执行隔离需独立 VM 验收;普通数据库测试不代表 Linux 安全验收。
|
||||||
|
|
||||||
|
性能投影由 Coordinator 与验证后的最终 AC 同事务写入,平台仅有 SELECT 权限。
|
||||||
|
每个 Worker 可配置 `measurement_epoch`(64 位小写十六进制摘要)。运维须先核对实际
|
||||||
|
硬件、内核、CPU 调度/资源上限及测量配置,再固定该纪元;上述条件变化时更换纪元。
|
||||||
|
未配置纪元时正常判题与历史查询保留,统计不宣称可比较。组键还绑定不可变题目版本、
|
||||||
|
语言、精确工具链/镜像及编译/运行预算;它不含学生源码或提交身份。
|
||||||
|
|
||||||
|
`kf-coordinator --config /etc/kf-judge/coordinator.json --backfill-performance UUID`
|
||||||
|
是单条、有数据库期限的受控修复入口。它重新验证已保存报告的绑定、清单与摘要,
|
||||||
|
仅从原尝试已绑定的纪元生成缺失投影;重复运行不增加样本。新配置不能给无纪元的旧尝试
|
||||||
|
补造可信环境。原判题报告不改写。升级只有 Backend Alembic;存在纪元/投影时降级拒绝
|
||||||
|
丢弃数据,须另行进行显式数据保留迁移,不能用删除成绩绕过。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Service-only secrets from a private local file; never format database/token values.
|
//! Service-only secrets from a private local file; never format database/token values.
|
||||||
use kf_contracts::judging::{Identifier, LeaseToken};
|
use kf_contracts::judging::{Digest, Identifier, LeaseToken};
|
||||||
use kf_contracts::judging_versions::NodeAdmission;
|
use kf_contracts::judging_versions::NodeAdmission;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::{collections::HashSet, fs, io, net::SocketAddr, path::Path};
|
use std::{collections::HashSet, fs, io, net::SocketAddr, path::Path};
|
||||||
@@ -8,6 +8,10 @@ use std::{collections::HashSet, fs, io, net::SocketAddr, path::Path};
|
|||||||
pub struct WorkerIdentity {
|
pub struct WorkerIdentity {
|
||||||
pub id: Identifier,
|
pub id: Identifier,
|
||||||
pub token: LeaseToken,
|
pub token: LeaseToken,
|
||||||
|
/// Operator-attested measurement setup; absent means no comparable statistics.
|
||||||
|
/// Rotate whenever hardware/kernel/CPU scheduling or measurement setup changes.
|
||||||
|
#[serde(default)]
|
||||||
|
pub measurement_epoch: Option<Digest>,
|
||||||
}
|
}
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod database;
|
pub mod database;
|
||||||
|
pub mod performance;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ struct Cli {
|
|||||||
config: Option<PathBuf>,
|
config: Option<PathBuf>,
|
||||||
#[arg(long, env = "KF_COORDINATOR_LISTEN", default_value = "127.0.0.1:8100")]
|
#[arg(long, env = "KF_COORDINATOR_LISTEN", default_value = "127.0.0.1:8100")]
|
||||||
listen: SocketAddr,
|
listen: SocketAddr,
|
||||||
|
/// One bounded, operator-only repair from an already epoch-bound final report.
|
||||||
|
#[arg(long, requires = "config")]
|
||||||
|
backfill_performance: Option<uuid::Uuid>,
|
||||||
}
|
}
|
||||||
async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
@@ -25,7 +28,18 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
database: Database::new(&c.database_url)?,
|
database: Database::new(&c.database_url)?,
|
||||||
admission: c.admission.clone(),
|
admission: c.admission.clone(),
|
||||||
lease_seconds: c.lease_seconds,
|
lease_seconds: c.lease_seconds,
|
||||||
|
measurement_epochs: c
|
||||||
|
.workers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|w| w.measurement_epoch.clone().map(|epoch| (w.id, epoch)))
|
||||||
|
.collect(),
|
||||||
};
|
};
|
||||||
|
if let Some(evaluation) = cli.backfill_performance {
|
||||||
|
let evaluation = serde_json::from_value(serde_json::json!(evaluation))?;
|
||||||
|
let created = repository.backfill_performance(evaluation).await?;
|
||||||
|
println!("trusted performance rows created: {created}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let r = repository.clone();
|
let r = repository.clone();
|
||||||
let period = c.recovery_seconds;
|
let period = c.recovery_seconds;
|
||||||
task = Some(tokio::spawn(async move {
|
task = Some(tokio::spawn(async move {
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
//! Same-transaction final projections. An old unbound attempt stays unbound.
|
||||||
|
use crate::{
|
||||||
|
database::{Error, Result},
|
||||||
|
repository::hash,
|
||||||
|
};
|
||||||
|
use kf_contracts::{
|
||||||
|
judging::Digest,
|
||||||
|
judging_versions::{JobManifest, JudgementReport},
|
||||||
|
};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tokio_postgres::Transaction;
|
||||||
|
|
||||||
|
pub fn comparison_group(manifest: &JobManifest, epoch: &str) -> Result<String> {
|
||||||
|
let _: Digest = epoch.to_owned().try_into().map_err(|_| Error::Invalid)?;
|
||||||
|
let value = serde_json::to_value(manifest).map_err(|_| Error::Invalid)?;
|
||||||
|
let group = json!({
|
||||||
|
"version": 1, "measurement_epoch": epoch,
|
||||||
|
"schema_version": value["schema_version"],
|
||||||
|
"problem_version_id": value["problem_version_id"],
|
||||||
|
"language": value["language"], "toolchain": value["toolchain"],
|
||||||
|
"compile_limits": value["compile_limits"], "run_limits": value["run_limits"],
|
||||||
|
"job_wall_time_us": value["job_wall_time_us"],
|
||||||
|
});
|
||||||
|
Ok(hash(
|
||||||
|
&serde_json::to_vec(&group).map_err(|_| Error::Invalid)?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn record(
|
||||||
|
transaction: &Transaction<'_>,
|
||||||
|
manifest: &JobManifest,
|
||||||
|
report: &JudgementReport,
|
||||||
|
digest: &str,
|
||||||
|
) -> Result<u64> {
|
||||||
|
let fields = report.fields();
|
||||||
|
let b = fields.binding;
|
||||||
|
let Some(row) = transaction
|
||||||
|
.query_opt(
|
||||||
|
"SELECT a.measurement_epoch,s.id,s.language,e.updated_at FROM evaluations e \
|
||||||
|
JOIN judge_attempts a ON a.id=e.current_attempt_id AND a.evaluation_id=e.id \
|
||||||
|
JOIN submissions s ON s.id=e.submission_id WHERE e.id=$1 AND a.id=$2 \
|
||||||
|
AND e.state='finished' AND e.verdict='accepted' AND e.failure_code IS NULL \
|
||||||
|
AND a.cleanup_complete IS TRUE AND e.final_report_digest=$3 AND a.report_digest=$3",
|
||||||
|
&[&b.evaluation_id.as_uuid(), &b.attempt_id.as_uuid(), &digest],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(0);
|
||||||
|
};
|
||||||
|
let Some(epoch) = row.get::<_, Option<String>>("measurement_epoch") else {
|
||||||
|
return Ok(0);
|
||||||
|
};
|
||||||
|
let group = comparison_group(manifest, &epoch)?;
|
||||||
|
let value: Value = serde_json::to_value(report).map_err(|_| Error::Invalid)?;
|
||||||
|
let measurement = |name: &str| -> Result<Option<i64>> {
|
||||||
|
if value[name].is_null() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let value = value[name].as_u64().ok_or(Error::Invalid)?;
|
||||||
|
Ok(Some(i64::try_from(value).map_err(|_| Error::Invalid)?))
|
||||||
|
};
|
||||||
|
let cpu = measurement("run_cpu_time_us")?;
|
||||||
|
let memory = measurement("run_memory_peak_bytes")?;
|
||||||
|
let submission: uuid::Uuid = row.get("id");
|
||||||
|
let language: String = row.get("language");
|
||||||
|
let finished: std::time::SystemTime = row.get("updated_at");
|
||||||
|
Ok(transaction
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO evaluation_performance(evaluation_id,submission_id,attempt_id,\
|
||||||
|
problem_version_id,language,comparison_group,measurement_epoch,report_digest,\
|
||||||
|
finished_at,cpu_time_us,memory_peak_bytes) \
|
||||||
|
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT(evaluation_id) DO NOTHING",
|
||||||
|
&[
|
||||||
|
&b.evaluation_id.as_uuid(),
|
||||||
|
&submission,
|
||||||
|
&b.attempt_id.as_uuid(),
|
||||||
|
&manifest.problem_version_id().as_uuid(),
|
||||||
|
&language,
|
||||||
|
&group,
|
||||||
|
&epoch,
|
||||||
|
&digest,
|
||||||
|
&finished,
|
||||||
|
&cpu,
|
||||||
|
&memory,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grouping_ignores_submission_identity_and_separates_epoch_and_language() {
|
||||||
|
let bytes =
|
||||||
|
include_bytes!("../../../../tests/fixtures/contracts/judge-manifest.valid.json");
|
||||||
|
let mut value: Value = serde_json::from_slice(bytes).unwrap();
|
||||||
|
let manifest: JobManifest = serde_json::from_value(value.clone()).unwrap();
|
||||||
|
let epoch = "a".repeat(64);
|
||||||
|
let group = comparison_group(&manifest, &epoch).unwrap();
|
||||||
|
value["evaluation_id"] = json!("11111111-1111-4111-8111-111111111111");
|
||||||
|
value["submission_id"] = json!("22222222-2222-4222-8222-222222222222");
|
||||||
|
value["source"]["digest"] = json!("c".repeat(64));
|
||||||
|
let other: JobManifest = serde_json::from_value(value.clone()).unwrap();
|
||||||
|
assert_eq!(group, comparison_group(&other, &epoch).unwrap());
|
||||||
|
assert_ne!(group, comparison_group(&other, &"b".repeat(64)).unwrap());
|
||||||
|
value["language"] = json!("c17");
|
||||||
|
let other: JobManifest = serde_json::from_value(value).unwrap();
|
||||||
|
assert_ne!(group, comparison_group(&other, &epoch).unwrap());
|
||||||
|
assert!(comparison_group(&manifest, "unverified").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use kf_contracts::judging_versions::{
|
|||||||
use kf_contracts::{SchemaVersion, judging::*, judging_v2 as v2};
|
use kf_contracts::{SchemaVersion, judging::*, judging_v2 as v2};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sha2::{Digest as _, Sha256};
|
use sha2::{Digest as _, Sha256};
|
||||||
|
use std::collections::HashMap;
|
||||||
use tokio_postgres::{Row, Transaction};
|
use tokio_postgres::{Row, Transaction};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -13,6 +14,7 @@ pub struct Repository {
|
|||||||
pub database: Database,
|
pub database: Database,
|
||||||
pub admission: NodeAdmission,
|
pub admission: NodeAdmission,
|
||||||
pub lease_seconds: u32,
|
pub lease_seconds: u32,
|
||||||
|
pub measurement_epochs: HashMap<Identifier, Digest>,
|
||||||
}
|
}
|
||||||
pub fn identifier(v: Uuid) -> Identifier {
|
pub fn identifier(v: Uuid) -> Identifier {
|
||||||
v.to_string().try_into().expect("non-nil generated UUID")
|
v.to_string().try_into().expect("non-nil generated UUID")
|
||||||
@@ -165,7 +167,8 @@ impl Repository {
|
|||||||
.checked_add(1)
|
.checked_add(1)
|
||||||
.ok_or(Error::Invalid)?;
|
.ok_or(Error::Invalid)?;
|
||||||
let digest = hash(&m.canonical_bytes().map_err(invalid)?);
|
let digest = hash(&m.canonical_bytes().map_err(invalid)?);
|
||||||
let row=t.query_one("INSERT INTO judge_attempts(id,evaluation_id,worker_id,number,fencing_version,lease_token_hash,expires_at,manifest,manifest_digest) VALUES($1,$2,$3,$4,$5,$6,clock_timestamp()+$7::bigint*interval '1 second',$8,$9) RETURNING floor(extract(epoch from expires_at)*1000)::bigint",&[&attempt,&eid.as_uuid(),&worker.as_uuid(),&number,&fence,&hash(token.as_bytes()),&i64::from(self.lease_seconds),&as_value(&m)?,&digest]).await?;
|
let epoch = self.measurement_epochs.get(&worker).map(|e| e.as_str());
|
||||||
|
let row=t.query_one("INSERT INTO judge_attempts(id,evaluation_id,worker_id,number,fencing_version,lease_token_hash,expires_at,manifest,manifest_digest,measurement_epoch) VALUES($1,$2,$3,$4,$5,$6,clock_timestamp()+$7::bigint*interval '1 second',$8,$9,$10) RETURNING floor(extract(epoch from expires_at)*1000)::bigint",&[&attempt,&eid.as_uuid(),&worker.as_uuid(),&number,&fence,&hash(token.as_bytes()),&i64::from(self.lease_seconds),&as_value(&m)?,&digest,&epoch]).await?;
|
||||||
t.execute("UPDATE evaluations SET state='claimed',current_attempt_id=$2,attempts_count=$3,fencing_version=$4,updated_at=clock_timestamp() WHERE id=$1",&[&eid.as_uuid(),&attempt,&number,&fence]).await?;
|
t.execute("UPDATE evaluations SET state='claimed',current_attempt_id=$2,attempts_count=$3,fencing_version=$4,updated_at=clock_timestamp() WHERE id=$1",&[&eid.as_uuid(),&attempt,&number,&fence]).await?;
|
||||||
let grant = LeaseGrant::for_manifest(
|
let grant = LeaseGrant::for_manifest(
|
||||||
&m,
|
&m,
|
||||||
@@ -401,9 +404,46 @@ impl Repository {
|
|||||||
fields.cleanup_complete,
|
fields.cleanup_complete,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
crate::performance::record(&t, &m, r, &digest).await?;
|
||||||
t.commit().await?;
|
t.commit().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
pub async fn backfill_performance(&self, evaluation: Identifier) -> Result<u64> {
|
||||||
|
let mut s = self.database.session().await?;
|
||||||
|
let t = s.client.transaction().await?;
|
||||||
|
let row = t
|
||||||
|
.query_opt(
|
||||||
|
"SELECT final_report FROM evaluations WHERE id=$1 FOR UPDATE",
|
||||||
|
&[&evaluation.as_uuid()],
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.ok_or(Error::Rejected)?;
|
||||||
|
let value: Value = row.get::<_, Option<Value>>(0).ok_or(Error::Rejected)?;
|
||||||
|
let report: JudgementReport = as_wire(value.clone())?;
|
||||||
|
let b = report.fields().binding;
|
||||||
|
if b.evaluation_id != evaluation {
|
||||||
|
return Err(Error::Rejected);
|
||||||
|
}
|
||||||
|
let e = self.bound(&t, b, None).await?;
|
||||||
|
let manifest: JobManifest = as_wire(e.get("manifest"))?;
|
||||||
|
let manifest_digest: Digest = e
|
||||||
|
.get::<_, String>("manifest_digest")
|
||||||
|
.try_into()
|
||||||
|
.map_err(invalid)?;
|
||||||
|
report
|
||||||
|
.validate_for_manifest(&manifest, &manifest_digest)
|
||||||
|
.map_err(invalid)?;
|
||||||
|
report.validate_program_budget(&manifest).map_err(invalid)?;
|
||||||
|
let digest = hash(&serde_json::to_vec(&as_value(&report)?).map_err(invalid)?);
|
||||||
|
if e.get::<_, Option<String>>("final_report_digest").as_deref() != Some(&digest)
|
||||||
|
|| e.get::<_, Option<String>>("report_digest").as_deref() != Some(&digest)
|
||||||
|
{
|
||||||
|
return Err(Error::Rejected);
|
||||||
|
}
|
||||||
|
let count = crate::performance::record(&t, &manifest, &report, &digest).await?;
|
||||||
|
t.commit().await?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
pub async fn cancel(&self, eid: Identifier) -> Result<()> {
|
pub async fn cancel(&self, eid: Identifier) -> Result<()> {
|
||||||
let mut s = self.database.session().await?;
|
let mut s = self.database.session().await?;
|
||||||
let t = s.client.transaction().await?;
|
let t = s.client.transaction().await?;
|
||||||
|
|||||||
Reference in New Issue
Block a user