feat(database): add judging schema and separate write roles

This commit is contained in:
2026-10-09 01:09:34 +08:00
parent df52446dac
commit f4bf218081
11 changed files with 1307 additions and 1 deletions
+23
View File
@@ -0,0 +1,23 @@
# 数据库所有权
Alembic 是唯一的 schema 入口。先对专用 PostgreSQL 18 数据库运行 `uv run --locked alembic
upgrade head`,API 不在启动时创建表。迁移账号拥有该数据库对象并需要 `CREATEROLE`;它不作为
任何运行服务账号。生产配置及登录角色由部署方提供,不在源码里设置默认数据库口令。
初始迁移建立两个受限的 `NOLOGIN` 权限组:`kf_oj_platform` 和 `kf_oj_coordinator`。
平台组创建 submission / queued evaluation / outbox,不能指定或更新执行状态、attempt 或成绩。
协调器组领取和更新评测、插入 attempt / 用例成绩及恢复通知,不能修改账号、题库或提交身份。
Worker 没有数据库角色,也不需要数据库连接配置。
服务使用各自独立的普通 `LOGIN` 角色,分别加入对应权限组;登录角色不能拥有数据库/表、
不能加入迁移账号或其他高权限组,且必须为 `NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS`。
初始迁移拒绝已有的高权限同名组和继承其他角色的同名组。应用启动时不运行 migration。
降级删除本项目业务表与触发器,保留权限组及操作方配置的登录账号,避免删除集群级身份。
发布是一次单向转换:先在事务内创建版本和连续测试点,再设置 `published_at`。
数据库触发器拒绝再次发布、修改已发布版本和追加/改写已发布测试点;评测通过复合外键
永久绑定 submission 的版本,当前 attempt 和用例成绩也由复合外键绑定同一评测及版本。
工件引用具有数据库生成的 `ready` 状态,引用存在时不能转为 `deleting` 或删除元数据。
真实数据库验收使用显式 `KF_OJ_TEST_POSTGRES` 管理连接。每个测试只创建和删除随机命名的
`kf_oj_test_*` 数据库,不清空已有业务库。例行无数据库测试会明确跳过这些验收;跳过不算通过。
+6 -1
View File
@@ -5,14 +5,19 @@ from alembic import context
from sqlalchemy import Connection from sqlalchemy import Connection
from kf_oj.core.settings import Settings from kf_oj.core.settings import Settings
from kf_oj.infra.artifacts import models as artifact_models # noqa: F401
from kf_oj.infra.db.base import Base from kf_oj.infra.db.base import Base
from kf_oj.infra.db.session import build_engine from kf_oj.infra.db.session import build_engine
from kf_oj.infra.messaging import models as messaging_models # noqa: F401
from kf_oj.modules.identity import models as identity_models # noqa: F401
from kf_oj.modules.problems import models as problem_models # noqa: F401
from kf_oj.modules.submissions import models as submission_models # noqa: F401
config = context.config config = context.config
if config.config_file_name is not None: if config.config_file_name is not None:
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
# Register each business module's ORM models here when its schema is implemented. # Explicit composition root. Infra and domains do not import foreign ORM models.
target_metadata = Base.metadata target_metadata = Base.metadata
@@ -0,0 +1,588 @@
"""judging foundation and role boundaries
Revision ID: 0001_judging_foundation
Revises:
Create Date: 2026-10-09 00:57:34.004943
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0001_judging_foundation"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def install_guards_and_permissions() -> None:
# Serialize case insertion against the one-way publication transition.
op.execute("""
CREATE FUNCTION public.kf_immutable_case() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public AS $$
DECLARE published timestamptz;
BEGIN
IF TG_OP = 'UPDATE' THEN
RAISE EXCEPTION 'test cases are immutable' USING ERRCODE = '23514';
END IF;
SELECT published_at INTO published FROM public.problem_versions
WHERE id = CASE WHEN TG_OP = 'DELETE' THEN OLD.version_id ELSE NEW.version_id END
FOR UPDATE;
IF published IS NOT NULL THEN
RAISE EXCEPTION 'published test cases are immutable' USING ERRCODE = '23514';
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$
""")
op.execute("""
CREATE TRIGGER kf_immutable_case BEFORE INSERT OR UPDATE OR DELETE ON public.problem_cases
FOR EACH ROW EXECUTE FUNCTION public.kf_immutable_case()
""")
op.execute("""
CREATE FUNCTION public.kf_immutable_version() RETURNS trigger LANGUAGE plpgsql
SET search_path = pg_catalog, public AS $$
DECLARE count_cases integer; last_position integer;
BEGIN
IF TG_OP = 'INSERT' THEN
IF NEW.published_at IS NOT NULL THEN
RAISE EXCEPTION 'publish after inserting cases' USING ERRCODE = '23514';
END IF;
RETURN NEW;
END IF;
IF TG_OP = 'DELETE' THEN
IF OLD.published_at IS NOT NULL THEN
RAISE EXCEPTION 'published version is immutable' USING ERRCODE = '23514';
END IF;
RETURN OLD;
END IF;
IF OLD.published_at IS NOT NULL OR NEW.published_at IS NULL
OR (to_jsonb(NEW) - 'published_at') != (to_jsonb(OLD) - 'published_at') THEN
RAISE EXCEPTION 'version may only be published once' USING ERRCODE = '23514';
END IF;
SELECT count(*), max(position) INTO count_cases, last_position
FROM public.problem_cases WHERE version_id = OLD.id;
IF count_cases NOT BETWEEN 1 AND 256 OR last_position != count_cases - 1 THEN
RAISE EXCEPTION 'publication requires contiguous ordered cases'
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END $$
""")
op.execute("""
CREATE TRIGGER kf_immutable_version BEFORE INSERT OR UPDATE OR DELETE
ON public.problem_versions
FOR EACH ROW EXECUTE FUNCTION public.kf_immutable_version()
""")
op.execute("REVOKE ALL ON FUNCTION public.kf_immutable_case() FROM PUBLIC")
op.execute("REVOKE ALL ON FUNCTION public.kf_immutable_version() FROM PUBLIC")
# Migration owner needs CREATEROLE; runtime logins are separate, unprivileged group members.
for role in ("kf_oj_platform", "kf_oj_coordinator"):
op.execute(f"""
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{role}') THEN
CREATE ROLE {role} NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
ELSIF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{role}' AND
(rolcanlogin OR rolsuper OR rolcreatedb OR rolcreaterole OR rolbypassrls))
OR EXISTS (SELECT 1 FROM pg_auth_members m JOIN pg_roles r ON r.oid=m.member
WHERE r.rolname='{role}') THEN
RAISE EXCEPTION 'unsafe existing runtime role';
END IF;
END $$
""")
tables = (
"accounts,sessions,login_throttles,artifacts,problems,problem_versions,problem_cases,"
"submissions,evaluations,judge_attempts,case_results,outbox"
)
op.execute(f"REVOKE ALL ON {tables} FROM PUBLIC, kf_oj_platform, kf_oj_coordinator")
op.execute("REVOKE CREATE ON SCHEMA public FROM PUBLIC")
op.execute("GRANT USAGE ON SCHEMA public TO kf_oj_platform, kf_oj_coordinator")
op.execute(f"GRANT SELECT ON {tables} TO kf_oj_platform")
op.execute("GRANT INSERT,UPDATE,DELETE ON accounts,sessions,login_throttles TO kf_oj_platform")
op.execute("GRANT INSERT,SELECT,DELETE ON artifacts TO kf_oj_platform")
op.execute("GRANT UPDATE(state) ON artifacts TO kf_oj_platform")
op.execute(
"GRANT INSERT ON problems,problem_versions,problem_cases,submissions,outbox "
"TO kf_oj_platform"
)
op.execute("GRANT UPDATE(active_version_id,enabled) ON problems TO kf_oj_platform")
op.execute("GRANT UPDATE(published_at) ON problem_versions TO kf_oj_platform")
op.execute("GRANT INSERT(id,submission_id,problem_version_id) ON evaluations TO kf_oj_platform")
op.execute(
"GRANT UPDATE(failures,next_attempt_at,locked_until,lock_owner,delivered_at,"
"last_error_code) ON outbox TO kf_oj_platform"
)
op.execute(
"GRANT SELECT ON problems,problem_versions,problem_cases,artifacts,submissions,"
"evaluations,judge_attempts,case_results,outbox TO kf_oj_coordinator"
)
op.execute(
"GRANT UPDATE(state,fencing_version,attempts_count,current_attempt_id,not_before,"
"cancel_requested_at,updated_at,verdict,failure_code,final_report,final_report_digest) "
"ON evaluations TO kf_oj_coordinator"
)
op.execute("GRANT INSERT ON judge_attempts,case_results TO kf_oj_coordinator")
op.execute(
"GRANT UPDATE(expires_at,revoked_at,sequence,stage,case_index,report,report_digest,"
"cleanup_complete) ON judge_attempts TO kf_oj_coordinator"
)
# Recovery may enqueue the same immutable notification, but cannot change identity fields.
op.execute("GRANT INSERT ON outbox TO kf_oj_coordinator")
op.execute("GRANT UPDATE(next_attempt_at,delivered_at) ON outbox TO kf_oj_coordinator")
def upgrade() -> None:
op.create_table(
"accounts",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("username", sa.String(length=24), nullable=False),
sa.Column("password_hash", sa.Text(), nullable=False),
sa.Column("enabled", sa.Boolean(), server_default="true", nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint(
"username ~ '^[a-z0-9_]{3,24}$'", name=op.f("ck_accounts_canonical_username")
),
sa.CheckConstraint(
"char_length(password_hash) BETWEEN 40 AND 256",
name=op.f("ck_accounts_password_hash_size"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_accounts")),
sa.UniqueConstraint("username", name=op.f("uq_accounts_username")),
)
op.create_table(
"artifacts",
sa.Column("digest", sa.String(length=64), nullable=False),
sa.Column("bytes", sa.BigInteger(), nullable=False),
sa.Column("state", sa.String(length=8), server_default="ready", nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("digest ~ '^[0-9a-f]{64}$'", name=op.f("ck_artifacts_digest")),
sa.CheckConstraint("state IN ('ready', 'deleting')", name=op.f("ck_artifacts_state")),
sa.CheckConstraint("bytes BETWEEN 0 AND 67108864", name=op.f("ck_artifacts_bytes")),
sa.PrimaryKeyConstraint("digest", name=op.f("pk_artifacts")),
sa.UniqueConstraint("digest", "state", name=op.f("uq_artifacts_digest")),
)
op.create_table(
"login_throttles",
sa.Column("bucket_hash", sa.String(length=64), nullable=False),
sa.Column("attempts", sa.Integer(), nullable=False),
sa.Column("reset_at", sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint(
"bucket_hash ~ '^[0-9a-f]{64}$'", name=op.f("ck_login_throttles_bucket_digest")
),
sa.CheckConstraint(
"attempts BETWEEN 1 AND 10", name=op.f("ck_login_throttles_attempt_budget")
),
sa.PrimaryKeyConstraint("bucket_hash", name=op.f("pk_login_throttles")),
)
op.create_index("ix_login_throttles_reset", "login_throttles", ["reset_at"], unique=False)
op.create_table(
"problems",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("slug", sa.String(length=64), nullable=False),
sa.Column("active_version_id", sa.Uuid(), nullable=True),
sa.Column("enabled", sa.Boolean(), server_default="true", nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("slug ~ '^[a-z0-9][a-z0-9_-]{0,63}$'", name=op.f("ck_problems_slug")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_problems")),
sa.UniqueConstraint("slug", name=op.f("uq_problems_slug")),
)
op.create_table(
"problem_versions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("problem_id", sa.Uuid(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("title", sa.String(length=160), nullable=False),
sa.Column("statement", sa.Text(), nullable=False),
sa.Column("languages", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("toolchain", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("compile_limits", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("run_limits", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("job_wall_time_us", sa.BigInteger(), nullable=False),
sa.Column("comparison", sa.String(length=16), server_default="exact_bytes", nullable=False),
sa.Column("public_samples", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint(
"comparison = 'exact_bytes'", name=op.f("ck_problem_versions_comparison")
),
sa.CheckConstraint(
"char_length(title) BETWEEN 1 AND 160", name=op.f("ck_problem_versions_title_size")
),
sa.CheckConstraint(
"job_wall_time_us BETWEEN 1000 AND 3600000000",
name=op.f("ck_problem_versions_job_wall"),
),
sa.CheckConstraint(
"octet_length(statement) BETWEEN 1 AND 262144",
name=op.f("ck_problem_versions_statement_bytes"),
),
sa.CheckConstraint("revision > 0", name=op.f("ck_problem_versions_revision")),
sa.ForeignKeyConstraint(
["problem_id"], ["problems.id"], name=op.f("fk_problem_versions_problem_id_problems")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_problem_versions")),
sa.UniqueConstraint("id", "problem_id", name=op.f("uq_problem_versions_id")),
sa.UniqueConstraint("problem_id", "revision", name=op.f("uq_problem_versions_problem_id")),
)
op.create_index(
"ix_problem_versions_public",
"problem_versions",
["problem_id", "published_at"],
unique=False,
)
op.create_table(
"sessions",
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("account_id", sa.Uuid(), nullable=False),
sa.Column("csrf_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("csrf_hash ~ '^[0-9a-f]{64}$'", name=op.f("ck_sessions_csrf_digest")),
sa.CheckConstraint("token_hash ~ '^[0-9a-f]{64}$'", name=op.f("ck_sessions_token_digest")),
sa.CheckConstraint("expires_at > created_at", name=op.f("ck_sessions_expiry")),
sa.ForeignKeyConstraint(
["account_id"], ["accounts.id"], name=op.f("fk_sessions_account_id_accounts")
),
sa.PrimaryKeyConstraint("token_hash", name=op.f("pk_sessions")),
)
op.create_index(
"ix_sessions_account_expiry", "sessions", ["account_id", "expires_at"], unique=False
)
op.create_table(
"problem_cases",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("version_id", sa.Uuid(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("input_digest", sa.String(length=64), nullable=False),
sa.Column("expected_digest", sa.String(length=64), nullable=False),
sa.Column(
"artifact_state",
sa.String(length=8),
sa.Computed("'ready'", persisted=True),
nullable=False,
),
sa.CheckConstraint("position BETWEEN 0 AND 255", name=op.f("ck_problem_cases_position")),
sa.ForeignKeyConstraint(
["expected_digest", "artifact_state"],
["artifacts.digest", "artifacts.state"],
name="fk_problem_cases_expected_digest_artifacts",
),
sa.ForeignKeyConstraint(
["input_digest", "artifact_state"],
["artifacts.digest", "artifacts.state"],
name=op.f("fk_problem_cases_input_digest_artifacts"),
),
sa.ForeignKeyConstraint(
["version_id"],
["problem_versions.id"],
name=op.f("fk_problem_cases_version_id_problem_versions"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_problem_cases")),
sa.UniqueConstraint("id", "version_id", name=op.f("uq_problem_cases_id")),
sa.UniqueConstraint("version_id", "position", name=op.f("uq_problem_cases_version_id")),
)
op.create_table(
"submissions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("account_id", sa.Uuid(), nullable=False),
sa.Column("problem_id", sa.Uuid(), nullable=False),
sa.Column("problem_version_id", sa.Uuid(), nullable=False),
sa.Column("language", sa.String(length=8), nullable=False),
sa.Column("source_digest", sa.String(length=64), nullable=False),
sa.Column(
"artifact_state",
sa.String(length=8),
sa.Computed("'ready'", persisted=True),
nullable=False,
),
sa.Column("idempotency_key", sa.Uuid(), nullable=False),
sa.Column("request_digest", sa.String(length=64), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("language IN ('c17', 'cpp20')", name=op.f("ck_submissions_language")),
sa.CheckConstraint(
"request_digest ~ '^[0-9a-f]{64}$'", name=op.f("ck_submissions_request_digest")
),
sa.ForeignKeyConstraint(
["account_id"], ["accounts.id"], name=op.f("fk_submissions_account_id_accounts")
),
sa.ForeignKeyConstraint(
["problem_version_id", "problem_id"],
["problem_versions.id", "problem_versions.problem_id"],
name=op.f("fk_submissions_problem_version_id_problem_versions"),
),
sa.ForeignKeyConstraint(
["source_digest", "artifact_state"],
["artifacts.digest", "artifacts.state"],
name=op.f("fk_submissions_source_digest_artifacts"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_submissions")),
sa.UniqueConstraint(
"account_id", "idempotency_key", name=op.f("uq_submissions_account_id")
),
sa.UniqueConstraint("id", "problem_version_id", name=op.f("uq_submissions_id")),
)
op.create_index(
"ix_submissions_account_history",
"submissions",
["account_id", "created_at", "id"],
unique=False,
)
op.create_table(
"evaluations",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("submission_id", sa.Uuid(), nullable=False),
sa.Column("problem_version_id", sa.Uuid(), nullable=False),
sa.Column("state", sa.String(length=16), server_default="queued", nullable=False),
sa.Column("fencing_version", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("attempts_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("max_attempts", sa.Integer(), server_default="3", nullable=False),
sa.Column("current_attempt_id", sa.Uuid(), nullable=True),
sa.Column(
"not_before",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("cancel_requested_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("verdict", sa.String(length=32), nullable=True),
sa.Column("failure_code", sa.String(length=32), nullable=True),
sa.Column("final_report", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("final_report_digest", sa.String(length=64), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint(
"state IN ('queued','claimed','compiling','running','cancelling',"
"'finished','cancelled','failed')",
name=op.f("ck_evaluations_state"),
),
sa.CheckConstraint(
"state NOT IN ('claimed','compiling','running','cancelling') "
"OR current_attempt_id IS NOT NULL",
name=op.f("ck_evaluations_active_attempt"),
),
sa.CheckConstraint(
"attempts_count BETWEEN 0 AND max_attempts", name=op.f("ck_evaluations_attempts_count")
),
sa.CheckConstraint("fencing_version >= 0", name=op.f("ck_evaluations_fence")),
sa.CheckConstraint(
"max_attempts BETWEEN 1 AND 5", name=op.f("ck_evaluations_max_attempts")
),
sa.ForeignKeyConstraint(
["submission_id", "problem_version_id"],
["submissions.id", "submissions.problem_version_id"],
name=op.f("fk_evaluations_submission_id_submissions"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_evaluations")),
sa.UniqueConstraint("id", "problem_version_id", name=op.f("uq_evaluations_id")),
sa.UniqueConstraint("submission_id", name=op.f("uq_evaluations_submission_id")),
)
op.create_index(
"ix_evaluations_recovery", "evaluations", ["state", "not_before", "id"], unique=False
)
op.create_table(
"judge_attempts",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("evaluation_id", sa.Uuid(), nullable=False),
sa.Column("worker_id", sa.Uuid(), nullable=False),
sa.Column("number", sa.Integer(), nullable=False),
sa.Column("fencing_version", sa.BigInteger(), nullable=False),
sa.Column("lease_token_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("manifest", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("manifest_digest", sa.String(length=64), nullable=False),
sa.Column("sequence", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("stage", sa.String(length=16), nullable=True),
sa.Column("case_index", sa.Integer(), nullable=True),
sa.Column("report", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column("report_digest", sa.String(length=64), nullable=True),
sa.Column("cleanup_complete", sa.Boolean(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint(
"lease_token_hash ~ '^[0-9a-f]{64}$'", name=op.f("ck_judge_attempts_token_digest")
),
sa.CheckConstraint(
"manifest_digest ~ '^[0-9a-f]{64}$'", name=op.f("ck_judge_attempts_manifest_digest")
),
sa.CheckConstraint("fencing_version > 0", name=op.f("ck_judge_attempts_fence")),
sa.CheckConstraint("number BETWEEN 1 AND 5", name=op.f("ck_judge_attempts_number")),
sa.CheckConstraint("sequence >= 0", name=op.f("ck_judge_attempts_sequence")),
sa.ForeignKeyConstraint(
["evaluation_id"],
["evaluations.id"],
name=op.f("fk_judge_attempts_evaluation_id_evaluations"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_judge_attempts")),
sa.UniqueConstraint(
"evaluation_id", "fencing_version", name="uq_judge_attempts_evaluation_fence"
),
sa.UniqueConstraint("evaluation_id", "number", name="uq_judge_attempts_evaluation_number"),
sa.UniqueConstraint("id", "evaluation_id", name=op.f("uq_judge_attempts_id")),
)
op.create_index(
"ix_judge_attempts_expiry", "judge_attempts", ["expires_at", "evaluation_id"], unique=False
)
op.create_table(
"outbox",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("evaluation_id", sa.Uuid(), nullable=False),
sa.Column("kind", sa.String(length=32), server_default="judge.evaluation", nullable=False),
sa.Column("failures", sa.Integer(), server_default="0", nullable=False),
sa.Column(
"next_attempt_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("lock_owner", sa.Uuid(), nullable=True),
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error_code", sa.String(length=32), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.CheckConstraint("kind = 'judge.evaluation'", name=op.f("ck_outbox_kind")),
sa.CheckConstraint("failures BETWEEN 0 AND 100", name=op.f("ck_outbox_failure_budget")),
sa.ForeignKeyConstraint(
["evaluation_id"], ["evaluations.id"], name=op.f("fk_outbox_evaluation_id_evaluations")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_outbox")),
sa.UniqueConstraint("evaluation_id", name=op.f("uq_outbox_evaluation_id")),
)
op.create_index(
"ix_outbox_delivery", "outbox", ["delivered_at", "next_attempt_at", "id"], unique=False
)
op.create_table(
"case_results",
sa.Column("attempt_id", sa.Uuid(), nullable=False),
sa.Column("case_id", sa.Uuid(), nullable=False),
sa.Column("evaluation_id", sa.Uuid(), nullable=False),
sa.Column("problem_version_id", sa.Uuid(), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("verdict", sa.String(length=32), nullable=False),
sa.Column("cpu_time_us", sa.BigInteger(), nullable=True),
sa.Column("memory_peak_bytes", sa.BigInteger(), nullable=True),
sa.CheckConstraint(
"cpu_time_us IS NULL OR cpu_time_us >= 0", name=op.f("ck_case_results_cpu")
),
sa.CheckConstraint(
"memory_peak_bytes IS NULL OR memory_peak_bytes >= 0",
name=op.f("ck_case_results_memory"),
),
sa.CheckConstraint("position BETWEEN 0 AND 255", name=op.f("ck_case_results_position")),
sa.ForeignKeyConstraint(
["attempt_id", "evaluation_id"],
["judge_attempts.id", "judge_attempts.evaluation_id"],
name=op.f("fk_case_results_attempt_id_judge_attempts"),
),
sa.ForeignKeyConstraint(
["case_id", "problem_version_id"],
["problem_cases.id", "problem_cases.version_id"],
name=op.f("fk_case_results_case_id_problem_cases"),
),
sa.ForeignKeyConstraint(
["evaluation_id", "problem_version_id"],
["evaluations.id", "evaluations.problem_version_id"],
name=op.f("fk_case_results_evaluation_id_evaluations"),
),
sa.PrimaryKeyConstraint("attempt_id", "case_id", name=op.f("pk_case_results")),
sa.UniqueConstraint("attempt_id", "position", name=op.f("uq_case_results_attempt_id")),
)
op.create_foreign_key(
"fk_problems_active_version",
"problems",
"problem_versions",
["active_version_id", "id"],
["id", "problem_id"],
deferrable=True,
initially="DEFERRED",
)
op.create_foreign_key(
"fk_evaluations_current_attempt",
"evaluations",
"judge_attempts",
["current_attempt_id", "id"],
["id", "evaluation_id"],
deferrable=True,
initially="DEFERRED",
)
install_guards_and_permissions()
def downgrade() -> None:
op.drop_constraint("fk_evaluations_current_attempt", "evaluations", type_="foreignkey")
op.drop_constraint("fk_problems_active_version", "problems", type_="foreignkey")
op.drop_table("case_results")
op.drop_index("ix_outbox_delivery", table_name="outbox")
op.drop_table("outbox")
op.drop_index("ix_judge_attempts_expiry", table_name="judge_attempts")
op.drop_table("judge_attempts")
op.drop_index("ix_evaluations_recovery", table_name="evaluations")
op.drop_table("evaluations")
op.drop_index("ix_submissions_account_history", table_name="submissions")
op.drop_table("submissions")
op.drop_table("problem_cases")
op.drop_index("ix_sessions_account_expiry", table_name="sessions")
op.drop_table("sessions")
op.drop_index("ix_problem_versions_public", table_name="problem_versions")
op.drop_table("problem_versions")
op.drop_table("problems")
op.drop_index("ix_login_throttles_reset", table_name="login_throttles")
op.drop_table("login_throttles")
op.drop_table("artifacts")
op.drop_table("accounts")
op.execute("DROP FUNCTION public.kf_immutable_version()")
op.execute("DROP FUNCTION public.kf_immutable_case()")
# NOLOGIN groups are intentionally retained: a downgrade never deletes operator roles.
@@ -0,0 +1,21 @@
"""Metadata ownership and lifecycle for immutable, digest-addressed bytes."""
from sqlalchemy import BigInteger, CheckConstraint, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from kf_oj.infra.db.base import Base
from kf_oj.infra.db.columns import CreatedAt
class Artifact(CreatedAt, Base):
__tablename__ = "artifacts"
__table_args__ = (
CheckConstraint("digest ~ '^[0-9a-f]{64}$'", name="digest"),
CheckConstraint("bytes BETWEEN 0 AND 67108864", name="bytes"),
CheckConstraint("state IN ('ready', 'deleting')", name="state"),
UniqueConstraint("digest", "state"),
)
digest: Mapped[str] = mapped_column(String(64), primary_key=True)
bytes: Mapped[int] = mapped_column(BigInteger)
state: Mapped[str] = mapped_column(String(8), server_default="ready")
+12
View File
@@ -0,0 +1,12 @@
"""Shared column types; business schemas are registered by composition roots."""
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
class CreatedAt:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
@@ -0,0 +1,31 @@
"""Transactional notifications; contains IDs, never source or hidden artifacts."""
from datetime import datetime
from uuid import UUID
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column
from kf_oj.infra.db.base import Base
from kf_oj.infra.db.columns import CreatedAt
class Outbox(CreatedAt, Base):
__tablename__ = "outbox"
__table_args__ = (
CheckConstraint("failures BETWEEN 0 AND 100", name="failure_budget"),
CheckConstraint("kind = 'judge.evaluation'", name="kind"),
Index("ix_outbox_delivery", "delivered_at", "next_attempt_at", "id"),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
evaluation_id: Mapped[UUID] = mapped_column(ForeignKey("evaluations.id"), unique=True)
kind: Mapped[str] = mapped_column(String(32), server_default="judge.evaluation")
failures: Mapped[int] = mapped_column(server_default="0")
next_attempt_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
lock_owner: Mapped[UUID | None]
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error_code: Mapped[str | None] = mapped_column(String(32))
@@ -0,0 +1,52 @@
"""Identity-owned persistent account and revocable session records."""
from datetime import datetime
from uuid import UUID
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from kf_oj.infra.db.base import Base
from kf_oj.infra.db.columns import CreatedAt
class Account(CreatedAt, Base):
__tablename__ = "accounts"
__table_args__ = (
CheckConstraint("username ~ '^[a-z0-9_]{3,24}$'", name="canonical_username"),
CheckConstraint("char_length(password_hash) BETWEEN 40 AND 256", name="password_hash_size"),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(24), unique=True)
password_hash: Mapped[str] = mapped_column(Text)
enabled: Mapped[bool] = mapped_column(Boolean, server_default="true")
class Session(CreatedAt, Base):
__tablename__ = "sessions"
__table_args__ = (
CheckConstraint("token_hash ~ '^[0-9a-f]{64}$'", name="token_digest"),
CheckConstraint("csrf_hash ~ '^[0-9a-f]{64}$'", name="csrf_digest"),
CheckConstraint("expires_at > created_at", name="expiry"),
Index("ix_sessions_account_expiry", "account_id", "expires_at"),
)
token_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
account_id: Mapped[UUID] = mapped_column(ForeignKey("accounts.id"))
csrf_hash: Mapped[str] = mapped_column(String(64))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class LoginThrottle(Base):
__tablename__ = "login_throttles"
__table_args__ = (
CheckConstraint("bucket_hash ~ '^[0-9a-f]{64}$'", name="bucket_digest"),
CheckConstraint("attempts BETWEEN 1 AND 10", name="attempt_budget"),
Index("ix_login_throttles_reset", "reset_at"),
)
bucket_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
attempts: Mapped[int]
reset_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
@@ -0,0 +1,96 @@
"""Immutable published versions, ordered cases, and mutable public availability."""
from datetime import datetime
from uuid import UUID
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
Computed,
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from kf_oj.infra.db.base import Base
from kf_oj.infra.db.columns import CreatedAt
class Problem(CreatedAt, Base):
__tablename__ = "problems"
__table_args__ = (
CheckConstraint("slug ~ '^[a-z0-9][a-z0-9_-]{0,63}$'", name="slug"),
ForeignKeyConstraint(
["active_version_id", "id"],
["problem_versions.id", "problem_versions.problem_id"],
name="fk_problems_active_version",
use_alter=True,
deferrable=True,
initially="DEFERRED",
),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(64), unique=True)
active_version_id: Mapped[UUID | None]
enabled: Mapped[bool] = mapped_column(Boolean, server_default="true")
class ProblemVersion(CreatedAt, Base):
__tablename__ = "problem_versions"
__table_args__ = (
UniqueConstraint("id", "problem_id"),
UniqueConstraint("problem_id", "revision"),
CheckConstraint("revision > 0", name="revision"),
CheckConstraint("octet_length(statement) BETWEEN 1 AND 262144", name="statement_bytes"),
CheckConstraint("char_length(title) BETWEEN 1 AND 160", name="title_size"),
CheckConstraint("comparison = 'exact_bytes'", name="comparison"),
CheckConstraint("job_wall_time_us BETWEEN 1000 AND 3600000000", name="job_wall"),
Index("ix_problem_versions_public", "problem_id", "published_at"),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
problem_id: Mapped[UUID] = mapped_column(ForeignKey("problems.id"))
revision: Mapped[int] = mapped_column(Integer)
title: Mapped[str] = mapped_column(String(160))
statement: Mapped[str] = mapped_column(Text)
languages: Mapped[list[str]] = mapped_column(JSONB)
toolchain: Mapped[dict] = mapped_column(JSONB)
compile_limits: Mapped[dict] = mapped_column(JSONB)
run_limits: Mapped[dict] = mapped_column(JSONB)
job_wall_time_us: Mapped[int] = mapped_column(BigInteger)
comparison: Mapped[str] = mapped_column(String(16), server_default="exact_bytes")
public_samples: Mapped[list[dict]] = mapped_column(JSONB)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ProblemCase(Base):
__tablename__ = "problem_cases"
__table_args__ = (
UniqueConstraint("id", "version_id"),
UniqueConstraint("version_id", "position"),
CheckConstraint("position BETWEEN 0 AND 255", name="position"),
ForeignKeyConstraint(
["input_digest", "artifact_state"], ["artifacts.digest", "artifacts.state"]
),
ForeignKeyConstraint(
["expected_digest", "artifact_state"],
["artifacts.digest", "artifacts.state"],
name="fk_problem_cases_expected_digest_artifacts",
),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
version_id: Mapped[UUID] = mapped_column(ForeignKey("problem_versions.id"))
position: Mapped[int]
input_digest: Mapped[str] = mapped_column(String(64))
expected_digest: Mapped[str] = mapped_column(String(64))
artifact_state: Mapped[str] = mapped_column(String(8), Computed("'ready'", persisted=True))
@@ -0,0 +1,163 @@
"""Platform creates initial rows; Coordinator exclusively advances execution."""
from datetime import datetime
from uuid import UUID
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
Computed,
DateTime,
ForeignKey,
ForeignKeyConstraint,
Index,
String,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from kf_oj.infra.db.base import Base
from kf_oj.infra.db.columns import CreatedAt
class Submission(CreatedAt, Base):
__tablename__ = "submissions"
__table_args__ = (
UniqueConstraint("account_id", "idempotency_key"),
UniqueConstraint("id", "problem_version_id"),
CheckConstraint("language IN ('c17', 'cpp20')", name="language"),
CheckConstraint("request_digest ~ '^[0-9a-f]{64}$'", name="request_digest"),
ForeignKeyConstraint(
["problem_version_id", "problem_id"],
["problem_versions.id", "problem_versions.problem_id"],
),
ForeignKeyConstraint(
["source_digest", "artifact_state"], ["artifacts.digest", "artifacts.state"]
),
Index("ix_submissions_account_history", "account_id", "created_at", "id"),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
account_id: Mapped[UUID] = mapped_column(ForeignKey("accounts.id"))
problem_id: Mapped[UUID]
problem_version_id: Mapped[UUID]
language: Mapped[str] = mapped_column(String(8))
source_digest: Mapped[str] = mapped_column(String(64))
artifact_state: Mapped[str] = mapped_column(String(8), Computed("'ready'", persisted=True))
idempotency_key: Mapped[UUID]
request_digest: Mapped[str] = mapped_column(String(64))
class Evaluation(CreatedAt, Base):
__tablename__ = "evaluations"
__table_args__ = (
UniqueConstraint("id", "problem_version_id"),
ForeignKeyConstraint(
["submission_id", "problem_version_id"],
["submissions.id", "submissions.problem_version_id"],
),
ForeignKeyConstraint(
["current_attempt_id", "id"],
["judge_attempts.id", "judge_attempts.evaluation_id"],
name="fk_evaluations_current_attempt",
use_alter=True,
deferrable=True,
initially="DEFERRED",
),
CheckConstraint(
"state IN ('queued','claimed','compiling','running','cancelling',"
"'finished','cancelled','failed')",
name="state",
),
CheckConstraint("fencing_version >= 0", name="fence"),
CheckConstraint("max_attempts BETWEEN 1 AND 5", name="max_attempts"),
CheckConstraint("attempts_count BETWEEN 0 AND max_attempts", name="attempts_count"),
CheckConstraint(
"state NOT IN ('claimed','compiling','running','cancelling') "
"OR current_attempt_id IS NOT NULL",
name="active_attempt",
),
Index("ix_evaluations_recovery", "state", "not_before", "id"),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
submission_id: Mapped[UUID] = mapped_column(unique=True)
problem_version_id: Mapped[UUID]
state: Mapped[str] = mapped_column(String(16), server_default="queued")
fencing_version: Mapped[int] = mapped_column(BigInteger, server_default="0")
attempts_count: Mapped[int] = mapped_column(server_default="0")
max_attempts: Mapped[int] = mapped_column(server_default="3")
current_attempt_id: Mapped[UUID | None]
not_before: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
cancel_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
verdict: Mapped[str | None] = mapped_column(String(32))
failure_code: Mapped[str | None] = mapped_column(String(32))
final_report: Mapped[dict | None] = mapped_column(JSONB)
final_report_digest: Mapped[str | None] = mapped_column(String(64))
class JudgeAttempt(CreatedAt, Base):
__tablename__ = "judge_attempts"
__table_args__ = (
UniqueConstraint("id", "evaluation_id"),
UniqueConstraint("evaluation_id", "number", name="uq_judge_attempts_evaluation_number"),
UniqueConstraint(
"evaluation_id", "fencing_version", name="uq_judge_attempts_evaluation_fence"
),
CheckConstraint("number BETWEEN 1 AND 5", name="number"),
CheckConstraint("fencing_version > 0", name="fence"),
CheckConstraint("lease_token_hash ~ '^[0-9a-f]{64}$'", name="token_digest"),
CheckConstraint("manifest_digest ~ '^[0-9a-f]{64}$'", name="manifest_digest"),
CheckConstraint("sequence >= 0", name="sequence"),
Index("ix_judge_attempts_expiry", "expires_at", "evaluation_id"),
)
id: Mapped[UUID] = mapped_column(primary_key=True)
evaluation_id: Mapped[UUID] = mapped_column(ForeignKey("evaluations.id"))
worker_id: Mapped[UUID]
number: Mapped[int]
fencing_version: Mapped[int] = mapped_column(BigInteger)
lease_token_hash: Mapped[str] = mapped_column(String(64))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
manifest: Mapped[dict] = mapped_column(JSONB)
manifest_digest: Mapped[str] = mapped_column(String(64))
sequence: Mapped[int] = mapped_column(BigInteger, server_default="0")
stage: Mapped[str | None] = mapped_column(String(16))
case_index: Mapped[int | None]
report: Mapped[dict | None] = mapped_column(JSONB)
report_digest: Mapped[str | None] = mapped_column(String(64))
cleanup_complete: Mapped[bool | None] = mapped_column(Boolean)
class CaseResult(Base):
__tablename__ = "case_results"
__table_args__ = (
UniqueConstraint("attempt_id", "position"),
ForeignKeyConstraint(
["case_id", "problem_version_id"], ["problem_cases.id", "problem_cases.version_id"]
),
ForeignKeyConstraint(
["evaluation_id", "problem_version_id"],
["evaluations.id", "evaluations.problem_version_id"],
),
ForeignKeyConstraint(
["attempt_id", "evaluation_id"], ["judge_attempts.id", "judge_attempts.evaluation_id"]
),
CheckConstraint("position BETWEEN 0 AND 255", name="position"),
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"),
)
attempt_id: Mapped[UUID] = mapped_column(primary_key=True)
case_id: Mapped[UUID] = mapped_column(primary_key=True)
evaluation_id: Mapped[UUID]
problem_version_id: Mapped[UUID]
position: Mapped[int]
verdict: Mapped[str] = mapped_column(String(32))
cpu_time_us: Mapped[int | None] = mapped_column(BigInteger)
memory_peak_bytes: Mapped[int | None] = mapped_column(BigInteger)
+53
View File
@@ -0,0 +1,53 @@
"""Optional real PostgreSQL acceptance; never reuse or reset an existing business DB."""
import asyncio
import os
import subprocess
from pathlib import Path
from uuid import uuid4
import asyncpg
import pytest
from sqlalchemy.engine import make_url
@pytest.fixture
def postgres_url():
admin = os.environ.get("KF_OJ_TEST_POSTGRES")
if admin is None:
pytest.skip("real PostgreSQL acceptance requires KF_OJ_TEST_POSTGRES")
url = make_url(admin)
database = "kf_oj_test_" + uuid4().hex
assert url.drivername == "postgresql+asyncpg"
test_url = url.set(database=database).render_as_string(hide_password=False)
async def manage(command):
connection = await asyncpg.connect(
url.set(drivername="postgresql").render_as_string(hide_password=False)
)
try:
await connection.execute(command)
finally:
await connection.close()
asyncio.run(manage(f'CREATE DATABASE "{database}"'))
try:
migrate(test_url, "upgrade", "head")
yield test_url
finally:
asyncio.run(manage(f'DROP DATABASE "{database}"'))
def migrate(url: str, command: str, target: str | None = None) -> None:
subprocess.run(
[str(Path(__file__).resolve().parents[1] / ".venv/bin/alembic"), command]
+ ([target] if target else []),
cwd=Path(__file__).resolve().parents[1],
env=os.environ | {"KF_OJ_DATABASE_URL": url},
check=True,
capture_output=True,
)
async def connect(url: str):
return await asyncpg.connect(url.replace("postgresql+asyncpg:", "postgresql:", 1))
+262
View File
@@ -0,0 +1,262 @@
import asyncio
import json
from uuid import uuid4
import asyncpg
import pytest
from conftest import connect, migrate
def test_migration_roundtrip_and_role_safety(postgres_url):
async def inspect():
c = await connect(postgres_url)
try:
tables = set(await c.fetch("SELECT tablename FROM pg_tables WHERE schemaname='public'"))
assert len(tables) == 13 # Twelve business tables and Alembic history.
roles = await c.fetch("SELECT * FROM pg_roles WHERE rolname LIKE 'kf_oj_%'")
assert {row["rolname"] for row in roles} == {"kf_oj_platform", "kf_oj_coordinator"}
assert all(
not any(
r[k]
for k in (
"rolsuper",
"rolcanlogin",
"rolcreatedb",
"rolcreaterole",
"rolbypassrls",
)
)
for r in roles
)
await c.execute("SET ROLE kf_oj_platform")
with pytest.raises(asyncpg.InsufficientPrivilegeError):
await c.execute("CREATE TABLE forbidden_table (x int)")
await c.execute("RESET ROLE")
finally:
await c.close()
asyncio.run(inspect())
migrate(postgres_url, "downgrade", "base")
migrate(postgres_url, "upgrade", "head")
migrate(postgres_url, "check")
asyncio.run(inspect())
async def fixture_rows(c):
account, problem, version, case, submission, evaluation = (uuid4() for _ in range(6))
await c.execute(
"INSERT INTO accounts (id,username,password_hash) VALUES ($1,'fixture_user',$2)",
account,
"synthetic-irreversible-placeholder-hash-" * 2,
)
await c.execute("INSERT INTO artifacts (digest,bytes) VALUES ($1,3),($2,0)", "a" * 64, "b" * 64)
await c.execute("INSERT INTO problems (id,slug) VALUES ($1,'fixture-problem')", problem)
await c.execute(
"""INSERT INTO problem_versions
(id,problem_id,revision,title,statement,languages,toolchain,compile_limits,run_limits,
job_wall_time_us,public_samples)
VALUES ($1,$2,1,'Fixture','Public statement','["c17"]','{}','{}','{}',10000,'[]')""",
version,
problem,
)
await c.execute(
"""INSERT INTO problem_cases
(id,version_id,position,input_digest,expected_digest) VALUES ($1,$2,0,$3,$4)""",
case,
version,
"b" * 64,
"a" * 64,
)
await c.execute("UPDATE problem_versions SET published_at=now() WHERE id=$1", version)
await c.execute("UPDATE problems SET active_version_id=$1 WHERE id=$2", version, problem)
await c.execute(
"""INSERT INTO submissions
(id,account_id,problem_id,problem_version_id,language,source_digest,idempotency_key,
request_digest) VALUES ($1,$2,$3,$4,'c17',$5,$6,$7)""",
submission,
account,
problem,
version,
"a" * 64,
uuid4(),
"c" * 64,
)
await c.execute(
"INSERT INTO evaluations (id,submission_id,problem_version_id) VALUES ($1,$2,$3)",
evaluation,
submission,
version,
)
await c.execute("INSERT INTO outbox (id,evaluation_id) VALUES ($1,$2)", uuid4(), evaluation)
return account, problem, version, case, submission, evaluation
def test_database_write_authorities_and_immutable_versions(postgres_url):
async def run():
c = await connect(postgres_url)
try:
await c.execute("SET ROLE kf_oj_platform")
ids = await fixture_rows(c)
assert await c.fetchval("SELECT state FROM evaluations WHERE id=$1", ids[5]) == "queued"
for command in (
"UPDATE evaluations SET state='finished'",
"INSERT INTO evaluations (id,submission_id,problem_version_id,state) "
"SELECT gen_random_uuid(),submission_id,problem_version_id,'finished' "
"FROM evaluations",
"DELETE FROM evaluations",
"UPDATE problem_versions SET title='altered'",
"INSERT INTO judge_attempts (id) VALUES (gen_random_uuid())",
"UPDATE artifacts SET bytes=42",
):
with pytest.raises(asyncpg.InsufficientPrivilegeError):
await c.execute(command)
with pytest.raises(asyncpg.CheckViolationError):
await c.execute("UPDATE problem_versions SET published_at=now()")
with pytest.raises(asyncpg.CheckViolationError):
await c.execute(
"""INSERT INTO problem_cases
(id,version_id,position,input_digest,expected_digest)
VALUES ($1,$2,1,$3,$3)""",
uuid4(),
ids[2],
"a" * 64,
)
with pytest.raises(asyncpg.ForeignKeyViolationError):
await c.execute("UPDATE artifacts SET state='deleting' WHERE digest=$1", "a" * 64)
await c.execute("RESET ROLE")
await c.execute("SET ROLE kf_oj_coordinator")
for command in (
"UPDATE accounts SET enabled=false",
"INSERT INTO problems (id,slug) VALUES (gen_random_uuid(),'forbidden')",
"UPDATE submissions SET language='cpp20'",
):
with pytest.raises(asyncpg.InsufficientPrivilegeError):
await c.execute(command)
attempt = uuid4()
async with c.transaction():
await c.execute(
"""INSERT INTO judge_attempts
(id,evaluation_id,worker_id,number,fencing_version,lease_token_hash,
expires_at,manifest,manifest_digest)
VALUES ($1,$2,$3,1,1,$4,now()+interval '30 seconds',$5,$6)""",
attempt,
ids[5],
uuid4(),
"d" * 64,
json.dumps({"fixture": True}),
"e" * 64,
)
await c.execute(
"""UPDATE evaluations SET current_attempt_id=$1,state='claimed',
attempts_count=1,fencing_version=1 WHERE id=$2""",
attempt,
ids[5],
)
assert await c.fetchval("SELECT state FROM evaluations") == "claimed"
with pytest.raises(asyncpg.InsufficientPrivilegeError):
await c.execute("UPDATE judge_attempts SET manifest='{}'")
# Active-attempt FK is deferred but still rejects an unrelated/absent attempt at commit.
with pytest.raises(asyncpg.ForeignKeyViolationError):
async with c.transaction():
await c.execute("UPDATE evaluations SET current_attempt_id=$1", uuid4())
finally:
await c.close()
asyncio.run(run())
def test_initial_creation_transaction_rolls_back_and_cross_version_fk_rejects(postgres_url):
async def run():
c = await connect(postgres_url)
try:
with pytest.raises(RuntimeError):
async with c.transaction():
await c.execute("SET LOCAL ROLE kf_oj_platform")
await fixture_rows(c)
raise RuntimeError("simulated transaction failure")
assert await c.fetchval("SELECT count(*) FROM submissions") == 0
assert await c.fetchval("SELECT count(*) FROM outbox") == 0
ids = await fixture_rows(c)
with pytest.raises(asyncpg.ForeignKeyViolationError):
await c.execute(
"UPDATE evaluations SET problem_version_id=$1 WHERE id=$2",
uuid4(),
ids[5],
)
finally:
await c.close()
asyncio.run(run())
def test_publication_serializes_concurrent_case_insertion(postgres_url):
async def run():
c, other = await connect(postgres_url), await connect(postgres_url)
pending = None
transaction = None
try:
ids = await fixture_rows(c)
# A new draft version, separate from the immutable already published fixture.
draft = uuid4()
await c.execute(
"""INSERT INTO problem_versions
(id,problem_id,revision,title,statement,languages,toolchain,compile_limits,
run_limits,job_wall_time_us,public_samples)
SELECT $1,problem_id,2,title,statement,languages,toolchain,compile_limits,
run_limits,job_wall_time_us,public_samples FROM problem_versions WHERE id=$2
""",
draft,
ids[2],
)
await c.execute(
"""INSERT INTO problem_cases
(id,version_id,position,input_digest,expected_digest)
VALUES ($1,$2,0,$3,$3)""",
uuid4(),
draft,
"a" * 64,
)
transaction = c.transaction()
await transaction.start()
await c.execute("UPDATE problem_versions SET published_at=now() WHERE id=$1", draft)
pending = asyncio.create_task(
other.execute(
"""INSERT INTO problem_cases
(id,version_id,position,input_digest,expected_digest)
VALUES ($1,$2,1,$3,$3)""",
uuid4(),
draft,
"a" * 64,
)
)
await asyncio.sleep(0.1)
assert not pending.done() # Actual row lock, not a simulated repository response.
await transaction.commit()
transaction = None
with pytest.raises(asyncpg.CheckViolationError):
await asyncio.wait_for(pending, timeout=5)
assert (
await c.fetchval("SELECT count(*) FROM problem_cases WHERE version_id=$1", draft)
== 1
)
with pytest.raises(asyncpg.CheckViolationError):
await c.execute(
"""INSERT INTO problem_versions
(id,problem_id,revision,title,statement,languages,toolchain,compile_limits,
run_limits,job_wall_time_us,public_samples,published_at)
SELECT $1,problem_id,3,title,statement,languages,toolchain,compile_limits,
run_limits,job_wall_time_us,public_samples,now()
FROM problem_versions WHERE id=$2""",
uuid4(),
draft,
)
finally:
if transaction:
await transaction.rollback()
if pending and not pending.done():
pending.cancel()
await asyncio.gather(pending, return_exceptions=True)
await c.close()
await other.close()
asyncio.run(run())