feat(coordinator): persist fenced execution authority and atomic results

This commit is contained in:
2026-10-09 07:15:24 +08:00
parent 38738122b3
commit f838008c71
22 changed files with 2152 additions and 50 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ class EvaluationState(StrEnum):
self.QUEUED,
},
self.RUNNING: {self.FINISHED, self.CANCELLING, self.FAILED, self.QUEUED},
self.CANCELLING: {self.CANCELLED, self.FAILED},
self.CANCELLING: {self.CANCELLED, self.FAILED, self.QUEUED},
}
return target in transitions.get(self, set())
+8
View File
@@ -352,12 +352,20 @@ class AdmissionPolicy(WireModel):
raise ValueError("job exceeds trusted node budget")
class StoppedReport(WireModel):
schema_version: Version
kind: Literal["judge.stopped"]
binding: LeaseBinding
cleanup_complete: StrictBool
WIRE_MODELS = {
"judge-manifest": JobManifest,
"judge-lease": LeaseGrant,
"judge-stage": StageReport,
"judge-result": JudgementReport,
"judge-admission": AdmissionPolicy,
"judge-stopped": StoppedReport,
}
+606
View File
@@ -0,0 +1,606 @@
"""Actual Rust HTTP + PostgreSQL state tests, using explicitly synthetic reports.
No program is compiled/run by this host suite. Root stop proofs and lease-loss
execution are separately required in the independent VM (F05/F06).
"""
import asyncio
import json
import secrets
import socket
import subprocess
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import UUID, uuid4
import asyncpg
import httpx
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import create_async_engine
from test_problems import import_fixture, platform_role
from kf_oj.api.app import create_app
from kf_oj.contracts.submissions import CreateSubmission
from kf_oj.core.settings import Settings
ROOT = Path(__file__).resolve().parents[2]
@asynccontextmanager
async def fixture(postgres_url, tmp_path, monkeypatch):
_, policy = import_fixture(tmp_path / "import")
engine = create_async_engine(postgres_url)
platform_role(engine)
monkeypatch.setattr("kf_oj.api.app.build_engine", lambda _: engine)
app = create_app(
Settings(
_env_file=None,
environment="test",
database_url=postgres_url,
artifact_root=tmp_path / "artifacts",
judge_admission=policy,
)
)
url = make_url(postgres_url)
c = await asyncpg.connect(
url.set(drivername="postgresql").render_as_string(hide_password=False)
)
role = "jp_coord_" + uuid4().hex
password = secrets.token_hex(32)
await c.execute(f"CREATE ROLE {role} LOGIN PASSWORD '{password}'")
await c.execute(f"GRANT kf_oj_coordinator TO {role}")
coord_url = url.set(drivername="postgresql", username=role, password=password).render_as_string(
hide_password=False
)
# Only loopback control-plane service is started; finite actual systemd scope.
with socket.socket() as reserved:
reserved.bind(("127.0.0.1", 0))
port = reserved.getsockname()[1]
workers = [dict(id=str(uuid4()), token=secrets.token_hex(32)) for _ in range(2)]
platform = secrets.token_hex(32)
config = tmp_path / "coordinator.json"
config.write_text(
json.dumps(
dict(
listen=f"127.0.0.1:{port}",
database_url=coord_url,
workers=workers,
platform_token=platform,
admission=policy.model_dump(mode="json"),
lease_seconds=5,
recovery_seconds=1,
)
)
)
config.chmod(0o600)
unit = "kf-jp-coord-" + uuid4().hex
binary = ROOT / "judge/target/debug/kf-coordinator"
assert binary.is_file(), "build actual Coordinator before this acceptance"
subprocess.run(
[
"systemd-run",
"--user",
f"--unit={unit}",
"--collect",
"--service-type=exec",
"--property=MemoryMax=128M",
"--property=MemorySwapMax=0",
"--property=CPUQuota=50%",
"--property=TasksMax=32",
"--property=RuntimeMaxSec=180",
"--property=NoNewPrivileges=yes",
str(binary),
"--config",
str(config),
],
check=True,
capture_output=True,
)
try:
async with app.router.lifespan_context(app):
owner = await app.state.identity.create_account(
"coordinator_fixture", secrets.token_urlsafe(24)
)
version = await app.state.problems.import_directory(tmp_path / "import")
async with httpx.AsyncClient(base_url=f"http://127.0.0.1:{port}", timeout=10) as client:
for _ in range(100):
try:
if (await client.get("/health/ready")).status_code == 200:
break
except httpx.TransportError:
pass
await asyncio.sleep(0.05)
else:
raise AssertionError("actual Coordinator not ready")
props = subprocess.check_output(
[
"systemctl",
"--user",
"show",
unit,
"--property=ControlGroup,MemoryMax,MemorySwapMax,TasksMax,CPUQuotaPerSecUSec,RuntimeMaxUSec,MainPID",
],
text=True,
)
values = dict(line.split("=", 1) for line in props.splitlines())
group = Path("/sys/fs/cgroup") / values["ControlGroup"].lstrip("/")
assert {
n: (group / n).read_text().strip()
for n in ["memory.max", "memory.swap.max", "pids.max", "cpu.max"]
} == {
"memory.max": "134217728",
"memory.swap.max": "0",
"pids.max": "32",
"cpu.max": "50000 100000",
}
async def create():
return await app.state.submissions.create(
owner,
CreateSubmission(
problem_version_id=version,
language="c17",
idempotency_key=uuid4(),
source="synthetic report fixture; never compiled on host",
),
)
client.coordinator_unit = unit
client.coordinator_role = role
yield client, c, workers, platform, create
finally:
subprocess.run(["systemctl", "--user", "stop", unit], check=True, capture_output=True)
assert (
subprocess.run(
["systemctl", "--user", "is-active", unit], capture_output=True
).returncode
!= 0
)
await c.execute(f"DROP ROLE {role}")
await c.close()
def notice(eid):
return dict(schema_version=1, kind="judge.evaluation", evaluation_id=str(eid))
def worker_headers(worker, grant=None):
headers = {"Authorization": "Bearer " + worker["token"]}
if grant:
headers["X-Lease-Token"] = grant["lease_token"]
return headers
async def claim(client, created, worker):
r = await client.post(
"/internal/claim", json=notice(created.evaluation_id), headers=worker_headers(worker)
)
assert r.status_code == 200, r.text
return r.json()
async def manifest(c, g):
v = await c.fetchval(
"SELECT manifest FROM judge_attempts WHERE id=$1", UUID(g["binding"]["attempt_id"])
)
return json.loads(v)
async def stages(client, worker, g):
h = worker_headers(worker, g)
for seq, stage, index in [(1, "compiling", None), (2, "running", 0), (3, "running", 1)]:
r = await client.post(
"/internal/stage",
headers=h,
json=dict(
schema_version=1,
kind="judge.stage",
binding=g["binding"],
sequence=seq,
stage=stage,
case_index=index,
),
)
assert r.status_code == 204, r.text
def synthetic_report(g, m, verdict="accepted"):
cases = [
dict(case_id=c["case_id"], verdict=verdict, cpu_time_us=100, memory_peak_bytes=4096)
for c in m["cases"]
]
return dict(
schema_version=1,
kind="judge.result",
binding=g["binding"],
manifest_digest=g["manifest_digest"],
compilation=dict(
status="succeeded",
program=dict(digest="d" * 64, bytes=4096),
diagnostics="",
diagnostics_truncated=False,
cpu_time_us=1000,
memory_peak_bytes=16384,
),
cases=cases,
verdict=verdict,
cleanup_complete=True,
failure_code=None,
run_cpu_time_us=len(cases) * 100,
run_memory_peak_bytes=4096,
)
def test_actual_auth_claim_competition_stage_fence_and_report(postgres_url, tmp_path, monkeypatch):
async def run():
async with fixture(postgres_url, tmp_path, monkeypatch) as (
client,
c,
workers,
platform,
create,
):
created = await create()
n = notice(created.evaluation_id)
assert (await client.post("/internal/claim", json=n)).status_code == 401
assert (
await client.post(
"/internal/claim", json=n, headers={"Authorization": "Bearer " + platform}
)
).status_code == 409
r = await client.post(
"/internal/claim",
content=b"x" * 262145,
headers=worker_headers(workers[0]) | {"Content-Type": "application/json"},
)
assert r.status_code == 413
outcomes = await asyncio.gather(
*(
client.post("/internal/claim", json=n, headers=worker_headers(w))
for w in workers
)
)
assert sorted(r.status_code for r in outcomes) == [200, 204]
g = next(r.json() for r in outcomes if r.status_code == 200)
w = next(w for w in workers if w["id"] == g["binding"]["worker_id"])
other = next(w2 for w2 in workers if w2 != w)
assert await c.fetchval("SELECT count(*) FROM judge_attempts") == 1
assert (
await client.post(
"/internal/renew", json=g["binding"], headers=worker_headers(other, g)
)
).status_code == 409
assert (
await client.post(
"/internal/renew",
json=g["binding"] | {"fencing_version": 2},
headers=worker_headers(w, g),
)
).status_code == 409
bad = dict(
schema_version=1,
kind="judge.stage",
binding=g["binding"],
sequence=1,
stage="running",
case_index=0,
)
assert (
await client.post("/internal/stage", json=bad, headers=worker_headers(w, g))
).status_code == 409
await stages(client, w, g)
progress = await client.post(
"/internal/progress", json=g["binding"], headers=worker_headers(w, g)
)
assert progress.status_code == 200
assert progress.json() == {
"state": "running",
"sequence": 3,
"stage": "running",
"case_index": 1,
}
subprocess.run(
["systemctl", "--user", "restart", client.coordinator_unit],
check=True,
capture_output=True,
)
progress = await client.post(
"/internal/progress", json=g["binding"], headers=worker_headers(w, g)
)
assert progress.status_code == 200 and progress.json()["sequence"] == 3
assert (
await client.post("/internal/stage", json=bad, headers=worker_headers(w, g))
).status_code == 409
assert (
await client.post(
"/internal/renew", json=g["binding"], headers=worker_headers(w, g)
)
).status_code == 200
m = await manifest(c, g)
report = synthetic_report(g, m)
wrong = report | {"cases": list(reversed(report["cases"]))}
assert (
await client.post("/internal/report", json=wrong, headers=worker_headers(w, g))
).status_code == 422
assert await c.fetchval("SELECT count(*) FROM case_results") == 0
good = await client.post("/internal/report", json=report, headers=worker_headers(w, g))
assert good.status_code == 204, good.text
assert (
await client.post("/internal/report", json=report, headers=worker_headers(w, g))
).status_code == 204
assert (
await client.post(
"/internal/report",
json=synthetic_report(g, m, "wrong_answer"),
headers=worker_headers(w, g),
)
).status_code == 409
assert (
await c.fetchval("SELECT state FROM evaluations WHERE id=$1", created.evaluation_id)
== "finished"
)
assert await c.fetchval("SELECT count(*) FROM case_results") == 2
assert (
await client.post("/internal/claim", json=n, headers=worker_headers(w))
).status_code == 204
queued = await create()
await c.execute(
"UPDATE outbox SET delivered_at=clock_timestamp()-interval '31 seconds' "
"WHERE evaluation_id=$1",
queued.evaluation_id,
)
for _ in range(60):
if await c.fetchval(
"SELECT delivered_at IS NULL FROM outbox WHERE evaluation_id=$1",
queued.evaluation_id,
):
break
await asyncio.sleep(0.05)
else:
raise AssertionError("missing notification not compensated")
assert (
await c.fetchval("SELECT state FROM evaluations WHERE id=$1", queued.evaluation_id)
== "queued"
)
await claim(client, queued, w)
await c.execute(f"ALTER ROLE {client.coordinator_role} NOLOGIN")
assert (await client.get("/health/ready")).status_code == 503
assert (await client.get("/health/live")).status_code == 200
await c.execute(f"ALTER ROLE {client.coordinator_role} LOGIN")
asyncio.run(run())
def test_actual_report_transaction_rollback(postgres_url, tmp_path, monkeypatch):
async def run():
async with fixture(postgres_url, tmp_path, monkeypatch) as (client, c, workers, _, create):
created = await create()
w = workers[0]
g = await claim(client, created, w)
await stages(client, w, g)
m = await manifest(c, g)
await c.execute("""CREATE FUNCTION test_case_reject() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN IF NEW.position=1 THEN
RAISE EXCEPTION 'synthetic rollback';
END IF; RETURN NEW; END $$""")
await c.execute(
"CREATE TRIGGER test_case_reject BEFORE INSERT ON case_results "
"FOR EACH ROW EXECUTE FUNCTION test_case_reject()"
)
r = await client.post(
"/internal/report", json=synthetic_report(g, m), headers=worker_headers(w, g)
)
assert r.status_code == 503, r.text
assert await c.fetchval("SELECT count(*) FROM case_results") == 0
assert await c.fetchval("SELECT report FROM judge_attempts") is None
assert await c.fetchval("SELECT state FROM evaluations") == "running"
await c.execute("DROP TRIGGER test_case_reject ON case_results")
await c.execute("DROP FUNCTION test_case_reject()")
assert (
await client.post(
"/internal/report", json=synthetic_report(g, m), headers=worker_headers(w, g)
)
).status_code == 204
asyncio.run(run())
def test_actual_expiry_stopping_quarantine_retry_and_old_attempt(
postgres_url, tmp_path, monkeypatch
):
async def run():
async with fixture(postgres_url, tmp_path, monkeypatch) as (client, c, workers, _, create):
created = await create()
other = await create()
w = workers[0]
g = await claim(client, created, w)
# Expired server-side lease (not a client clock claim) causes actual recovery scan.
await c.execute(
"UPDATE judge_attempts SET expires_at=clock_timestamp()-interval '1 second'"
)
assert (
await client.post(
"/internal/renew", json=g["binding"], headers=worker_headers(w, g)
)
).status_code == 409
for _ in range(40):
pending = (
await client.get("/internal/pending-stops", headers=worker_headers(w))
).json()
if pending:
break
await asyncio.sleep(0.05)
assert pending[0]["binding"] == g["binding"]
stopped = dict(
schema_version=1, kind="judge.stopped", binding=g["binding"], cleanup_complete=False
)
assert (
await client.post("/internal/stopped", json=stopped, headers=worker_headers(w))
).status_code == 204
assert (
await c.fetchval(
"SELECT failure_code FROM evaluations WHERE id=$1", created.evaluation_id
)
== "cleanup_failed"
)
assert (
await client.post(
"/internal/claim", json=notice(other.evaluation_id), headers=worker_headers(w)
)
).status_code == 429
stopped["cleanup_complete"] = True
# Synthetic stop assertion only tests DB state. Actual Root proof is required in VM.
assert (
await client.post("/internal/stopped", json=stopped, headers=worker_headers(w))
).status_code == 204
assert (
await client.post("/internal/stopped", json=stopped, headers=worker_headers(w))
).status_code == 204
assert (
await c.fetchval("SELECT state FROM evaluations WHERE id=$1", created.evaluation_id)
== "queued"
)
await c.execute(
"UPDATE evaluations SET not_before=clock_timestamp() WHERE id=$1",
created.evaluation_id,
)
fresh = await claim(client, created, w)
assert fresh["attempt_number"] == 2 and fresh["binding"]["fencing_version"] == 2
assert (
await client.post(
"/internal/stage",
json=dict(
schema_version=1,
kind="judge.stage",
binding=g["binding"],
sequence=1,
stage="compiling",
case_index=None,
),
headers=worker_headers(w, g),
)
).status_code == 409
assert (
await client.post("/internal/stopped", json=stopped, headers=worker_headers(w))
).status_code == 409
asyncio.run(run())
def test_actual_user_cancel_all_stages_and_bounded_infrastructure_retry(
postgres_url, tmp_path, monkeypatch
):
async def run():
async with fixture(postgres_url, tmp_path, monkeypatch) as (
client,
c,
workers,
platform,
create,
):
w = workers[0]
for stage in ["queued", "compiling", "running"]:
created = await create()
g = None
if stage != "queued":
g = await claim(client, created, w)
r = dict(
schema_version=1,
kind="judge.stage",
binding=g["binding"],
sequence=1,
stage="compiling",
case_index=None,
)
assert (
await client.post("/internal/stage", json=r, headers=worker_headers(w, g))
).status_code == 204
if stage == "running":
r |= dict(sequence=2, stage="running", case_index=0)
assert (
await client.post(
"/internal/stage", json=r, headers=worker_headers(w, g)
)
).status_code == 204
assert (
await client.post(
"/internal/cancel",
json=notice(created.evaluation_id),
headers={"Authorization": "Bearer " + platform},
)
).status_code == 204
if g:
assert (
await c.fetchval(
"SELECT state FROM evaluations WHERE id=$1", created.evaluation_id
)
== "cancelling"
)
assert (
await client.post(
"/internal/renew", json=g["binding"], headers=worker_headers(w, g)
)
).status_code == 409
r = dict(
schema_version=1,
kind="judge.stopped",
binding=g["binding"],
cleanup_complete=True,
)
assert (
await client.post("/internal/stopped", json=r, headers=worker_headers(w))
).status_code == 204
assert (
await c.fetchval(
"SELECT state FROM evaluations WHERE id=$1", created.evaluation_id
)
== "cancelled"
)
created = await create()
for number in range(1, 4):
g = await claim(client, created, w)
m = await manifest(c, g)
report = synthetic_report(g, m) | dict(
compilation=dict(
status="system_error",
program=None,
diagnostics="",
diagnostics_truncated=False,
cpu_time_us=None,
memory_peak_bytes=None,
),
cases=[],
verdict="system_error",
failure_code="sandbox_unavailable",
run_cpu_time_us=None,
run_memory_peak_bytes=None,
)
assert (
await client.post("/internal/report", json=report, headers=worker_headers(w, g))
).status_code == 204
state = await c.fetchval(
"SELECT state FROM evaluations WHERE id=$1", created.evaluation_id
)
assert state == ("failed" if number == 3 else "queued")
if state == "queued":
await c.execute(
"UPDATE evaluations SET not_before=clock_timestamp() WHERE id=$1",
created.evaluation_id,
)
assert (
await client.post(
"/internal/claim", json=notice(created.evaluation_id), headers=worker_headers(w)
)
).status_code == 204
assert (
await c.fetchval(
"SELECT count(*) FROM judge_attempts WHERE evaluation_id=$1",
created.evaluation_id,
)
== 3
)
asyncio.run(run())
@@ -0,0 +1,69 @@
{
"$comment": "Structural schema; also run versioned model validation for cross-field budgets, result consistency, UUID canonical form and UTF-8 byte limits.",
"$defs": {
"LeaseBinding": {
"additionalProperties": false,
"properties": {
"attempt_id": {
"format": "uuid",
"title": "Attempt Id",
"type": "string"
},
"evaluation_id": {
"format": "uuid",
"title": "Evaluation Id",
"type": "string"
},
"fencing_version": {
"maximum": 9223372036854775807,
"minimum": 1,
"title": "Fencing Version",
"type": "integer"
},
"worker_id": {
"format": "uuid",
"title": "Worker Id",
"type": "string"
}
},
"required": [
"evaluation_id",
"attempt_id",
"worker_id",
"fencing_version"
],
"title": "LeaseBinding",
"type": "object"
}
},
"$id": "urn:kf-oj:judge-stopped:v1",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"binding": {
"$ref": "#/$defs/LeaseBinding"
},
"cleanup_complete": {
"title": "Cleanup Complete",
"type": "boolean"
},
"kind": {
"const": "judge.stopped",
"title": "Kind",
"type": "string"
},
"schema_version": {
"const": 1,
"title": "Schema Version",
"type": "integer"
}
},
"required": [
"schema_version",
"kind",
"binding",
"cleanup_complete"
],
"title": "StoppedReport",
"type": "object"
}
+521 -6
View File
@@ -61,6 +61,17 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -119,6 +130,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -134,12 +151,27 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -152,6 +184,17 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core 0.10.1",
]
[[package]]
name = "clap"
version = "4.6.7"
@@ -192,12 +235,24 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -207,6 +262,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -217,14 +281,44 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "ctutils"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
dependencies = [
"cmov",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
]
[[package]]
@@ -237,6 +331,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "fallible-iterator"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -253,6 +353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -261,6 +362,12 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
@@ -274,6 +381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
@@ -289,12 +397,45 @@ dependencies = [
"version_check",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest 0.11.3",
]
[[package]]
name = "http"
version = "1.5.0"
@@ -340,6 +481,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.11.1"
@@ -415,9 +565,13 @@ dependencies = [
"clap",
"kf-contracts",
"serde",
"serde_json",
"sha2 0.10.9",
"tokio",
"tokio-postgres",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
@@ -440,7 +594,7 @@ dependencies = [
"seccompiler",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -453,7 +607,7 @@ dependencies = [
"kf-sandbox",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
]
[[package]]
@@ -468,12 +622,33 @@ version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libredox"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
dependencies = [
"bitflags",
"libc",
"plain",
"redox_syscall 0.9.4",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.34"
@@ -495,6 +670,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "md-5"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
dependencies = [
"cfg-if",
"digest 0.11.3",
]
[[package]]
name = "memchr"
version = "2.8.3"
@@ -539,18 +724,107 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.5.18",
"smallvec",
"windows-link",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_shared",
"serde",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "postgres-protocol"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514"
dependencies = [
"base64",
"byteorder",
"bytes",
"fallible-iterator",
"hmac",
"md-5",
"memchr",
"rand 0.10.3",
"sha2 0.11.0",
"stringprep",
]
[[package]]
name = "postgres-types"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be"
dependencies = [
"bytes",
"fallible-iterator",
"postgres-protocol",
"serde_core",
"serde_json",
"uuid",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -569,6 +843,82 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "redox_syscall"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e"
dependencies = [
"bitflags",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
@@ -611,6 +961,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seccompiler"
version = "0.5.0"
@@ -693,8 +1049,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
@@ -716,6 +1083,12 @@ dependencies = [
"libc",
]
[[package]]
name = "siphasher"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "slab"
version = "0.4.12"
@@ -738,6 +1111,17 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "stringprep"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
dependencies = [
"unicode-bidi",
"unicode-normalization",
"unicode-properties",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -781,12 +1165,19 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
@@ -807,6 +1198,46 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "tokio-postgres"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b40d66d9b2cfe04b628173409368e58247e8eddbbd3b0e6c6ba1d09f20f6c9e"
dependencies = [
"async-trait",
"byteorder",
"bytes",
"fallible-iterator",
"futures-channel",
"futures-util",
"log",
"parking_lot",
"percent-encoding",
"phf",
"pin-project-lite",
"postgres-protocol",
"postgres-types",
"rand 0.9.5",
"socket2",
"tokio",
"tokio-util",
"whoami",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"libc",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -916,12 +1347,33 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -934,6 +1386,7 @@ version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
dependencies = [
"getrandom 0.4.3",
"js-sys",
"serde_core",
"wasm-bindgen",
@@ -957,6 +1410,21 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
@@ -1002,6 +1470,27 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "whoami"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
dependencies = [
"libredox",
"wasite",
"web-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -1017,6 +1506,32 @@ dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
+14
View File
@@ -91,6 +91,7 @@ kind!(ManifestKind, "judge.manifest");
kind!(LeaseKind, "judge.lease");
kind!(StageKind, "judge.stage");
kind!(ResultKind, "judge.result");
kind!(StoppedKind, "judge.stopped");
kind!(ImageKind, "linux_gnu_cxx_x86_64_v1");
kind!(Comparison, "exact_bytes");
@@ -409,6 +410,19 @@ impl StageReport {
Ok(())
}
}
// A trusted ordinary Worker attests actual Root stop/recovery proof. No execution
// result is accepted through this path; current attempt/fence still bind it.
validated_struct!(StoppedReport {
schema_version: SchemaVersion,
kind: StoppedKind,
binding: LeaseBinding,
cleanup_complete: bool,
});
impl StoppedReport {
pub fn validate(&self) -> Validation {
self.binding.validate()
}
}
validated_struct!(CompilationReport {
status: CompilationStatus,
#[serde(deserialize_with = "required_option")] program: Option<ArtifactReference>,
+1 -1
View File
@@ -23,7 +23,7 @@ impl EvaluationState {
| (Claimed, Compiling | Cancelling | Failed | Queued)
| (Compiling, Running | Finished | Cancelling | Failed | Queued)
| (Running, Finished | Cancelling | Failed | Queued)
| (Cancelling, Cancelled | Failed)
| (Cancelling, Cancelled | Failed | Queued)
)
}
}
@@ -51,6 +51,8 @@ fn shared_judging_examples_match_python() {
roundtrip::<StageReport>(&bytes, invalid, &name);
} else if name.starts_with("judge-result.") {
roundtrip::<JudgementReport>(&bytes, invalid, &name);
} else if name.starts_with("judge-stopped.") {
roundtrip::<StoppedReport>(&bytes, invalid, &name);
} else if name.starts_with("judge-admission.") {
roundtrip::<AdmissionPolicy>(&bytes, invalid, &name);
} else {
+4
View File
@@ -11,8 +11,12 @@ axum.workspace = true
tokio.workspace = true
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
tokio-postgres = { version = "=0.7.15", features = ["with-serde_json-1", "with-uuid-1"] }
sha2 = "=0.10.9"
uuid = { version = "1", features = ["v4", "serde"] }
[lints]
workspace = true
+21
View File
@@ -0,0 +1,21 @@
# 持久化协调器
`kf-coordinator --config /etc/kf-judge/coordinator.json` 读取 0600、有界、单链接
私有配置。`listen` 为 loopback 地址,`database_url` 仅数值 loopback 或绝对 Unix socket,
使用继承 `kf_oj_coordinator` 的普通数据库角色;Backend Alembic 是唯一迁移入口。
`workers` 配置各 Worker 的 UUID 与独立64位十六进制 `token`,`platform_token`
与它们不同;同时配置可信 `admission`、`lease_seconds`(5–300)、
`recovery_seconds`(1–60)。内部身份与浏览器会话、短期执行租约分离。
新增 `tokio-postgres 0.7.15` 负责有限数据库连接和事务,`sha2 0.10.9` 绑定摘要。
依赖方向仍为 `coordinator → contracts`,不调用沙箱或维护另一套 migration。
`/health/live` 表示存活,`/health/ready` 核对数据库和角色权限。内部接口先认证,
请求有体积/期限/并发上限;claim 原子生成 attempt、可信数据库时间租约与递增 fence,
提交后才返回领取权。阶段更新、续租和报告都核对当前绑定与令牌;报告的用例、attempt
和终态同一事务提交,原样重复可确认,修改/旧 attempt 拒绝覆盖。
取消与过期先撤销执行权并进入 `cancelling`;实际清理证明到达前不释放容量。
清理失败保留 `cleanup_failed`,只有已确认停止的基础设施失败可按有限预算重试。
恢复扫描每批最多16项,待领取事件投递30秒后仍无执行权时补偿重新通知。
Worker 的实际停止证明与执行隔离需独立 VM 验收;普通数据库测试不代表 Linux 安全验收。
+207 -17
View File
@@ -1,21 +1,211 @@
use axum::{Json, Router, routing::get};
use serde::Serialize;
#[derive(Serialize)]
struct HealthResponse {
status: &'static str,
service: &'static str,
version: &'static str,
use crate::{config::Config, database::Error, repository::Repository};
use axum::{
Json, Router,
body::Bytes,
extract::{DefaultBodyLimit, Request, State},
http::{HeaderMap, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
};
use kf_contracts::{JudgeTaskNotification, judging::*};
use serde_json::{Value, json};
use std::{sync::Arc, time::Duration};
use tokio::sync::Semaphore;
#[derive(Clone)]
pub struct App {
pub repository: Repository,
pub config: Arc<Config>,
slots: Arc<Semaphore>,
}
async fn liveness() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok",
service: "coordinator",
version: env!("CARGO_PKG_VERSION"),
impl App {
pub fn new(repository: Repository, config: Config) -> Self {
Self {
repository,
config: Arc::new(config),
slots: Arc::new(Semaphore::new(16)),
}
}
}
type ApiResult<T> = Result<T, ApiError>;
pub struct ApiError(Error);
impl From<Error> for ApiError {
fn from(e: Error) -> Self {
Self(e)
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, code) = match self.0 {
Error::Busy => (StatusCode::TOO_MANY_REQUESTS, "capacity"),
Error::Unavailable => (StatusCode::SERVICE_UNAVAILABLE, "unavailable"),
Error::Rejected => (StatusCode::CONFLICT, "stale_or_foreign_authority"),
Error::Invalid => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_request"),
};
(status, Json(json!({"error":code}))).into_response()
}
}
fn token(headers: &HeaderMap) -> Option<&str> {
headers
.get("authorization")?
.to_str()
.ok()?
.strip_prefix("Bearer ")
}
fn equal(a: &str, b: &str) -> bool {
a.len() == 64
&& b.len() == 64
&& a.as_bytes()
.iter()
.zip(b.as_bytes())
.fold(0u8, |v, (a, b)| v | (a ^ b))
== 0
}
fn worker(app: &App, h: &HeaderMap) -> ApiResult<Identifier> {
let token = token(h).ok_or(ApiError(Error::Rejected))?;
app.config
.workers
.iter()
.find(|w| equal(token, w.token.expose_secret()))
.map(|w| w.id)
.ok_or(ApiError(Error::Rejected))
}
fn platform(app: &App, h: &HeaderMap) -> ApiResult<()> {
if token(h).is_some_and(|v| equal(v, app.config.platform_token.expose_secret())) {
Ok(())
} else {
Err(ApiError(Error::Rejected))
}
}
fn lease(h: &HeaderMap) -> ApiResult<&str> {
let v = h
.get("x-lease-token")
.and_then(|v| v.to_str().ok())
.ok_or(ApiError(Error::Rejected))?;
if v.len() != 64 {
return Err(ApiError(Error::Rejected));
}
Ok(v)
}
fn decode<T: serde::de::DeserializeOwned>(body: &Bytes) -> ApiResult<T> {
decode_wire(body).map_err(|_| ApiError(Error::Invalid))
}
fn bound_worker(app: &App, h: &HeaderMap, b: &LeaseBinding) -> ApiResult<()> {
if worker(app, h)? != b.worker_id {
return Err(ApiError(Error::Rejected));
}
Ok(())
}
async fn guard(State(app): State<App>, req: Request, next: Next) -> Response {
if req.uri().path().starts_with("/internal/")
&& !(worker(&app, req.headers()).is_ok() || platform(&app, req.headers()).is_ok())
{
return (
StatusCode::UNAUTHORIZED,
Json(json!({"error":"service_identity_required"})),
)
.into_response();
}
let Ok(_slot) = app.slots.clone().try_acquire_owned() else {
return ApiError(Error::Busy).into_response();
};
match tokio::time::timeout(Duration::from_secs(10), next.run(req)).await {
Ok(mut response) => {
response.headers_mut().insert(
"cache-control",
"no-store".parse().expect("constant header"),
);
response
}
Err(_) => (
StatusCode::REQUEST_TIMEOUT,
Json(json!({"error":"request_deadline"})),
)
.into_response(),
}
}
async fn live() -> Json<Value> {
Json(json!({"status":"ok","service":"coordinator","version":env!("CARGO_PKG_VERSION")}))
}
async fn ready(State(app): State<App>) -> ApiResult<Json<Value>> {
app.repository.database.ready().await?;
Ok(Json(json!({"status":"ready"})))
}
async fn claim(State(app): State<App>, h: HeaderMap, b: Bytes) -> ApiResult<Response> {
let w = worker(&app, &h)?;
let n: JudgeTaskNotification = decode(&b)?;
let eid = n
.evaluation_id
.to_string()
.try_into()
.map_err(|_| ApiError(Error::Invalid))?;
Ok(match app.repository.claim(eid, w).await? {
Some(g) => Json(g).into_response(),
None => StatusCode::NO_CONTENT.into_response(),
})
}
pub fn router() -> Router {
Router::new().route("/health/live", get(liveness))
async fn renew(State(app): State<App>, h: HeaderMap, b: Bytes) -> ApiResult<Json<Value>> {
let binding: LeaseBinding = decode(&b)?;
bound_worker(&app, &h, &binding)?;
Ok(Json(
json!({"expires_at_unix_ms":app.repository.renew(&binding,lease(&h)?).await?}),
))
}
async fn progress(State(app): State<App>, h: HeaderMap, b: Bytes) -> ApiResult<Json<Value>> {
let binding: LeaseBinding = decode(&b)?;
bound_worker(&app, &h, &binding)?;
Ok(Json(app.repository.progress(&binding, lease(&h)?).await?))
}
async fn stage(State(app): State<App>, h: HeaderMap, b: Bytes) -> ApiResult<StatusCode> {
let r: StageReport = decode(&b)?;
bound_worker(&app, &h, &r.binding)?;
app.repository.stage(&r, lease(&h)?).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn report(State(app): State<App>, h: HeaderMap, b: Bytes) -> ApiResult<StatusCode> {
let r: JudgementReport = decode(&b)?;
bound_worker(&app, &h, &r.binding)?;
app.repository.report(&r, lease(&h)?).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn stopped(State(app): State<App>, h: HeaderMap, b: Bytes) -> ApiResult<StatusCode> {
let r: StoppedReport = decode(&b)?;
bound_worker(&app, &h, &r.binding)?;
app.repository.stopped(&r).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn pending(State(app): State<App>, h: HeaderMap) -> ApiResult<Json<Value>> {
Ok(Json(app.repository.pending_stops(worker(&app, &h)?).await?))
}
async fn cancel(State(app): State<App>, h: HeaderMap, b: Bytes) -> ApiResult<StatusCode> {
platform(&app, &h)?;
let n: JudgeTaskNotification = decode(&b)?;
app.repository
.cancel(
n.evaluation_id
.to_string()
.try_into()
.map_err(|_| ApiError(Error::Invalid))?,
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub fn router() -> Router {
Router::new().route("/health/live", get(live))
}
pub fn application(app: App) -> Router {
Router::new()
.route("/health/live", get(live))
.route("/health/ready", get(ready))
.route("/internal/claim", post(claim))
.route("/internal/renew", post(renew))
.route("/internal/stage", post(stage))
.route("/internal/progress", post(progress))
.route("/internal/report", post(report))
.route("/internal/stopped", post(stopped))
.route("/internal/pending-stops", get(pending))
.route("/internal/cancel", post(cancel))
.layer(DefaultBodyLimit::max(MAX_WIRE_BYTES))
.layer(middleware::from_fn_with_state(app.clone(), guard))
.with_state(app)
}
+56
View File
@@ -0,0 +1,56 @@
//! Service-only secrets from a private local file; never format database/token values.
use kf_contracts::judging::{AdmissionPolicy, Identifier, LeaseToken};
use serde::Deserialize;
use std::{collections::HashSet, fs, io, net::SocketAddr, path::Path};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkerIdentity {
pub id: Identifier,
pub token: LeaseToken,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub listen: SocketAddr,
pub database_url: String,
pub workers: Vec<WorkerIdentity>,
pub platform_token: LeaseToken,
pub admission: AdmissionPolicy,
pub lease_seconds: u32,
pub recovery_seconds: u32,
}
impl Config {
pub fn load(path: &Path) -> io::Result<Self> {
let meta = fs::symlink_metadata(path)?;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
if !meta.is_file()
|| meta.nlink() != 1
|| meta.permissions().mode() & 0o777 != 0o600
|| meta.len() > 32768
{
return Err(io::Error::other(
"private coordinator configuration required",
));
}
let v: Self = serde_json::from_slice(&fs::read(path)?)
.map_err(|_| io::Error::other("invalid coordinator configuration"))?;
if !v.listen.ip().is_loopback()
|| !(5..=300).contains(&v.lease_seconds)
|| !(1..=60).contains(&v.recovery_seconds)
|| v.workers.is_empty()
|| v.workers.len() > 64
|| v.workers.iter().map(|w| w.id).collect::<HashSet<_>>().len() != v.workers.len()
|| v.workers
.iter()
.any(|w| w.token.expose_secret() == v.platform_token.expose_secret())
{
return Err(io::Error::other("invalid coordinator policy"));
}
let tokens: HashSet<_> = v.workers.iter().map(|w| w.token.expose_secret()).collect();
if tokens.len() != v.workers.len() {
return Err(io::Error::other("ambiguous worker identity"));
}
v.admission.validate().map_err(io::Error::other)?;
Ok(v)
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Finite connection slots and fixed SQL timeouts. Backend Alembic owns schema.
use std::{net::IpAddr, sync::Arc, time::Duration};
use tokio::{
sync::{OwnedSemaphorePermit, Semaphore},
task::JoinHandle,
};
use tokio_postgres::{Client, Config, NoTls, config::Host};
#[derive(Debug, Clone, Copy)]
pub enum Error {
Unavailable,
Busy,
Rejected,
Invalid,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("coordinator operation refused")
}
}
impl std::error::Error for Error {}
impl From<tokio_postgres::Error> for Error {
fn from(_: tokio_postgres::Error) -> Self {
Self::Unavailable
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Clone)]
pub struct Database {
config: Config,
slots: Arc<Semaphore>,
}
pub struct Session {
pub client: Client,
task: JoinHandle<()>,
_slot: OwnedSemaphorePermit,
}
impl Drop for Session {
fn drop(&mut self) {
self.task.abort();
}
}
impl Database {
pub fn new(url: &str) -> Result<Self> {
let mut config: Config = url.parse().map_err(|_| Error::Invalid)?;
if config.get_hosts().is_empty()
|| config.get_hosts().iter().any(|h| match h {
Host::Tcp(n) => !n.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback()),
Host::Unix(p) => !p.is_absolute(),
})
{
return Err(Error::Invalid);
}
config.connect_timeout(Duration::from_secs(3));
config.application_name("kf-coordinator");
config.options("-c statement_timeout=5000 -c lock_timeout=2000 -c idle_in_transaction_session_timeout=10000");
Ok(Self {
config,
slots: Arc::new(Semaphore::new(4)),
})
}
pub async fn session(&self) -> Result<Session> {
let slot = self
.slots
.clone()
.try_acquire_owned()
.map_err(|_| Error::Busy)?;
let (client, connection) = self.config.connect(NoTls).await?;
let task = tokio::spawn(async move {
let _ = connection.await;
});
let session = Session {
client,
task,
_slot: slot,
};
let row=session.client.query_one("SELECT NOT r.rolsuper AND NOT r.rolbypassrls AND pg_has_role(current_user,'kf_oj_coordinator','MEMBER') AND NOT has_table_privilege(current_user,'accounts','SELECT') AND NOT has_table_privilege(current_user,'problems','UPDATE') FROM pg_roles r WHERE r.rolname=session_user",&[]).await?;
if !row.get::<_, bool>(0) {
return Err(Error::Rejected);
}
Ok(session)
}
pub async fn ready(&self) -> Result<()> {
self.session()
.await?
.client
.query("SELECT 1 FROM evaluations LIMIT 1", &[])
.await?;
Ok(())
}
}
+4 -5
View File
@@ -1,6 +1,5 @@
//! Internal HTTP process and future lease/state authority.
//!
//! Only process liveness is exposed now. SQLx repositories and task transitions
//! are added after DR-01/PF-01 define their shared schema and concurrency rules.
//! Sole execution-state writer. The Backend owns migrations and initial rows.
pub mod api;
pub mod config;
pub mod database;
pub mod repository;
+54 -19
View File
@@ -1,31 +1,66 @@
use clap::Parser;
use std::net::SocketAddr;
use kf_coordinator::{
api::{App, application, router},
config::Config,
database::Database,
repository::Repository,
};
use std::{net::SocketAddr, path::PathBuf};
use tokio::net::TcpListener;
use tracing_subscriber::EnvFilter;
#[derive(Parser)]
#[command(version, about = "KF judge coordinator HTTP shell")]
#[command(version, about = "KF persistent judge coordinator")]
struct Cli {
#[arg(long, env = "KF_COORDINATOR_CONFIG")]
config: Option<PathBuf>,
#[arg(long, env = "KF_COORDINATOR_LISTEN", default_value = "127.0.0.1:8100")]
listen: SocketAddr,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.json()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init();
let listener = TcpListener::bind(Cli::parse().listen).await?;
tracing::info!(address = %listener.local_addr()?, "coordinator scaffold listening");
axum::serve(listener, kf_coordinator::api::router())
.with_graceful_shutdown(async {
if let Err(error) = tokio::signal::ctrl_c().await {
tracing::error!(%error, "failed to listen for shutdown signal");
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let mut task = None;
let (listen, app) = if let Some(path) = cli.config {
let c = Config::load(&path)?;
let listen = c.listen;
let repository = Repository {
database: Database::new(&c.database_url)?,
admission: c.admission.clone(),
lease_seconds: c.lease_seconds,
};
let r = repository.clone();
let period = c.recovery_seconds;
task = Some(tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(u64::from(period)));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tick.tick().await;
let _ = r.recover().await;
}
}));
(listen, application(App::new(repository, c)))
} else {
(cli.listen, router())
};
let listener = TcpListener::bind(listen).await?;
axum::serve(listener, app)
.with_graceful_shutdown(async {
let mut term =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("signal registration");
tokio::select! {_=tokio::signal::ctrl_c()=>{},_=term.recv()=>{}}
})
.await?;
if let Some(t) = task {
t.abort();
}
Ok(())
}
#[tokio::main]
async fn main() -> std::process::ExitCode {
match run().await {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(_) => {
eprintln!("coordinator unavailable");
std::process::ExitCode::from(2)
}
}
}
+436
View File
@@ -0,0 +1,436 @@
//! Execution authority is applied under row locks, using database clock and fencing.
use crate::database::{Database, Error, Result};
use kf_contracts::{SchemaVersion, judging::*};
use serde_json::{Value, json};
use sha2::{Digest as _, Sha256};
use tokio_postgres::{Row, Transaction};
use uuid::Uuid;
#[derive(Clone)]
pub struct Repository {
pub database: Database,
pub admission: AdmissionPolicy,
pub lease_seconds: u32,
}
pub fn identifier(v: Uuid) -> Identifier {
v.to_string().try_into().expect("non-nil generated UUID")
}
pub fn hash(data: &[u8]) -> String {
format!("{:x}", Sha256::digest(data))
}
fn secret() -> String {
format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple())
}
fn invalid(_: impl std::fmt::Debug) -> Error {
Error::Invalid
}
fn as_value<T: serde::Serialize>(v: &T) -> Result<Value> {
serde_json::to_value(v).map_err(invalid)
}
fn as_wire<T: serde::de::DeserializeOwned>(v: Value) -> Result<T> {
let b = serde_json::to_vec(&v).map_err(invalid)?;
decode_wire(&b).map_err(invalid)
}
impl Repository {
async fn manifest(&self, t: &Transaction<'_>, e: &Row) -> Result<JobManifest> {
let sid: Uuid = e.get("submission_id");
let eid: Uuid = e.get("id");
let h=t.query_opt("SELECT s.id,s.problem_version_id,s.language,s.source_digest,a.bytes,v.toolchain,v.compile_limits,v.run_limits,v.job_wall_time_us FROM submissions s JOIN problem_versions v ON v.id=s.problem_version_id JOIN artifacts a ON a.digest=s.source_digest AND a.state='ready' WHERE s.id=$1 AND v.published_at IS NOT NULL",&[&sid]).await?.ok_or(Error::Invalid)?;
let vid: Uuid = h.get("problem_version_id");
let rows=t.query("SELECT c.id,c.position,c.input_digest,c.expected_digest,i.bytes input_bytes,o.bytes expected_bytes FROM problem_cases c JOIN artifacts i ON i.digest=c.input_digest AND i.state='ready' JOIN artifacts o ON o.digest=c.expected_digest AND o.state='ready' WHERE c.version_id=$1 ORDER BY c.position LIMIT 257",&[&vid]).await?;
let mut cases = Vec::new();
for (n, r) in rows.iter().enumerate() {
if r.get::<_, i32>("position") != n as i32 {
return Err(Error::Invalid);
}
cases.push(TestCase {
case_id: identifier(r.get("id")),
input: ArtifactReference {
digest: r
.get::<_, String>("input_digest")
.try_into()
.map_err(invalid)?,
bytes: r.get::<_, i64>("input_bytes") as u64,
},
expected: ArtifactReference {
digest: r
.get::<_, String>("expected_digest")
.try_into()
.map_err(invalid)?,
bytes: r.get::<_, i64>("expected_bytes") as u64,
},
});
}
let m = JobManifest {
schema_version: SchemaVersion,
kind: ManifestKind::Value,
evaluation_id: identifier(eid),
submission_id: identifier(sid),
problem_version_id: identifier(vid),
language: as_wire(json!(h.get::<_, String>("language")))?,
toolchain: as_wire(h.get("toolchain"))?,
source: ArtifactReference {
digest: h
.get::<_, String>("source_digest")
.try_into()
.map_err(invalid)?,
bytes: h.get::<_, i64>("bytes") as u64,
},
cases,
comparison: Comparison::Value,
compile_limits: as_wire(h.get("compile_limits"))?,
run_limits: as_wire(h.get("run_limits"))?,
job_wall_time_us: h.get::<_, i64>("job_wall_time_us") as u64,
};
m.validate().map_err(invalid)?;
Ok(m)
}
pub async fn claim(&self, eid: Identifier, worker: Identifier) -> Result<Option<LeaseGrant>> {
let mut s = self.database.session().await?;
let t = s.client.transaction().await?;
// A fixed node-capacity lock is separate from platform publication/digest locks.
t.execute("SELECT pg_advisory_xact_lock(1262907471,0)", &[])
.await?;
let e=match t.query_opt("SELECT *,not_before<=clock_timestamp() due FROM evaluations WHERE id=$1 FOR UPDATE",&[&eid.as_uuid()]).await?{Some(e)=>e,None=>return Ok(None)};
let max = (e.get::<_, i32>("max_attempts") as u32).min(self.admission.max_retries + 1);
if e.get::<_, String>("state") != "queued"
|| !e.get::<_, bool>("due")
|| e.get::<_, Option<std::time::SystemTime>>("cancel_requested_at")
.is_some()
|| e.get::<_, i32>("attempts_count") as u32 >= max
{
return Ok(None);
}
let active:i64=t.query_one("SELECT count(*) FROM evaluations WHERE state IN ('claimed','compiling','running','cancelling')",&[]).await?.get(0);
if active >= i64::from(self.admission.max_concurrent_jobs) {
return Err(Error::Busy);
}
let m = self.manifest(&t, &e).await?;
let number = e.get::<_, i32>("attempts_count") + 1;
self.admission.admit(&m, number as u32).map_err(invalid)?;
let attempt = Uuid::new_v4();
let token = secret();
let fence = e
.get::<_, i64>("fencing_version")
.checked_add(1)
.ok_or(Error::Invalid)?;
let digest = hash(&manifest_bytes(&m).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?;
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 {
schema_version: SchemaVersion,
kind: LeaseKind::Value,
binding: LeaseBinding {
evaluation_id: eid,
attempt_id: identifier(attempt),
worker_id: worker,
fencing_version: fence as u64,
},
lease_token: serde_json::from_value(json!(token)).map_err(invalid)?,
attempt_number: number as u32,
expires_at_unix_ms: row.get::<_, i64>(0) as u64,
manifest_digest: digest.try_into().map_err(invalid)?,
};
t.commit().await?;
Ok(Some(grant))
}
async fn bound(
&self,
t: &Transaction<'_>,
b: &LeaseBinding,
token: Option<&str>,
) -> Result<Row> {
b.validate().map_err(invalid)?;
let row=t.query_opt("SELECT e.*,a.worker_id,a.fencing_version attempt_fence,a.expires_at>clock_timestamp() live,a.revoked_at,a.lease_token_hash,a.manifest,a.manifest_digest,a.sequence,a.stage,a.case_index,a.report_digest,a.cleanup_complete FROM evaluations e JOIN judge_attempts a ON a.id=e.current_attempt_id AND a.evaluation_id=e.id WHERE e.id=$1 AND a.id=$2 FOR UPDATE OF e,a",&[&b.evaluation_id.as_uuid(),&b.attempt_id.as_uuid()]).await?.ok_or(Error::Rejected)?;
if row.get::<_, Uuid>("worker_id") != b.worker_id.as_uuid()
|| row.get::<_, i64>("fencing_version") as u64 != b.fencing_version
|| row.get::<_, i64>("attempt_fence") as u64 != b.fencing_version
|| token.is_some_and(|s| hash(s.as_bytes()) != row.get::<_, String>("lease_token_hash"))
{
return Err(Error::Rejected);
}
Ok(row)
}
fn live(row: &Row) -> Result<()> {
if !row.get::<_, bool>("live")
|| row
.get::<_, Option<std::time::SystemTime>>("revoked_at")
.is_some()
|| row
.get::<_, Option<std::time::SystemTime>>("cancel_requested_at")
.is_some()
|| !matches!(
row.get::<_, String>("state").as_str(),
"claimed" | "compiling" | "running"
)
{
Err(Error::Rejected)
} else {
Ok(())
}
}
pub async fn renew(&self, b: &LeaseBinding, token: &str) -> Result<u64> {
let mut s = self.database.session().await?;
let t = s.client.transaction().await?;
let e = self.bound(&t, b, Some(token)).await?;
Self::live(&e)?;
let row=t.query_one("UPDATE judge_attempts SET expires_at=clock_timestamp()+$2::bigint*interval '1 second' WHERE id=$1 RETURNING floor(extract(epoch from expires_at)*1000)::bigint",&[&b.attempt_id.as_uuid(),&i64::from(self.lease_seconds)]).await?;
let time = row.get::<_, i64>(0) as u64;
t.commit().await?;
Ok(time)
}
pub async fn progress(&self, b: &LeaseBinding, token: &str) -> Result<Value> {
let mut s = self.database.session().await?;
let t = s.client.transaction().await?;
let e = self.bound(&t, b, Some(token)).await?;
Self::live(&e)?;
Ok(
json!({"state":e.get::<_,String>("state"),"sequence":e.get::<_,i64>("sequence"),"stage":e.get::<_,Option<String>>("stage"),"case_index":e.get::<_,Option<i32>>("case_index")}),
)
}
pub async fn stage(&self, report: &StageReport, token: &str) -> Result<()> {
report.validate().map_err(invalid)?;
let b = &report.binding;
let mut s = self.database.session().await?;
let t = s.client.transaction().await?;
let e = self.bound(&t, b, Some(token)).await?;
Self::live(&e)?;
let stage = match report.stage {
Stage::Compiling => "compiling",
Stage::Running => "running",
};
let index = report.case_index.map(i32::from);
let sequence = e.get::<_, i64>("sequence");
let old_stage = e.get::<_, Option<String>>("stage");
let old_index = e.get::<_, Option<i32>>("case_index");
if report.sequence as i64 == sequence
&& old_stage.as_deref() == Some(stage)
&& old_index == index
{
return Ok(());
}
if report.sequence as i64 != sequence.checked_add(1).ok_or(Error::Invalid)?
|| match report.stage {
Stage::Compiling => e.get::<_, String>("state") != "claimed" || old_stage.is_some(),
Stage::Running => {
!matches!(
e.get::<_, String>("state").as_str(),
"compiling" | "running"
) || index != Some(old_index.map_or(0, |v| v + 1))
}
}
{
return Err(Error::Rejected);
}
let m: JobManifest = as_wire(e.get("manifest"))?;
if index.is_some_and(|i| i as usize >= m.cases.len()) {
return Err(Error::Rejected);
}
t.execute(
"UPDATE judge_attempts SET sequence=$2,stage=$3,case_index=$4 WHERE id=$1",
&[
&b.attempt_id.as_uuid(),
&(report.sequence as i64),
&stage,
&index,
],
)
.await?;
t.execute(
"UPDATE evaluations SET state=$2,updated_at=clock_timestamp() WHERE id=$1",
&[&b.evaluation_id.as_uuid(), &stage],
)
.await?;
t.commit().await?;
Ok(())
}
async fn finish(
&self,
t: &Transaction<'_>,
e: &Row,
b: &LeaseBinding,
verdict: &str,
failure: Option<&str>,
clean: bool,
) -> Result<()> {
let cancelled = e
.get::<_, Option<std::time::SystemTime>>("cancel_requested_at")
.is_some();
let retries = (e.get::<_, i32>("max_attempts") as u32).min(self.admission.max_retries + 1);
let retry = !cancelled
&& clean
&& verdict == "system_error"
&& e.get::<_, i32>("attempts_count") < retries as i32;
let (state, v, code) = if !clean {
("cancelling", "system_error", Some("cleanup_failed"))
} else if cancelled {
("cancelled", "cancelled", None)
} else if retry {
("queued", verdict, failure)
} else if verdict == "cancelled" {
("cancelled", verdict, None)
} else if verdict == "system_error" {
("failed", verdict, failure)
} else {
("finished", verdict, None)
};
let delay = 2_i64.pow((e.get::<_, i32>("attempts_count") as u32).min(5));
t.execute("UPDATE judge_attempts SET cleanup_complete=$2,revoked_at=coalesce(revoked_at,clock_timestamp()) WHERE id=$1",&[&b.attempt_id.as_uuid(),&clean]).await?;
t.execute("UPDATE evaluations SET state=$2,verdict=$3,failure_code=$4,not_before=clock_timestamp()+$5::bigint*interval '1 second',updated_at=clock_timestamp() WHERE id=$1",&[&b.evaluation_id.as_uuid(),&state,&v,&code,&delay]).await?;
if retry {
t.execute("UPDATE evaluations SET verdict=NULL,failure_code=NULL,final_report=NULL,final_report_digest=NULL WHERE id=$1",&[&b.evaluation_id.as_uuid()]).await?;
t.execute("UPDATE outbox SET delivered_at=NULL,next_attempt_at=clock_timestamp()+$2::bigint*interval '1 second' WHERE evaluation_id=$1",&[&b.evaluation_id.as_uuid(),&delay]).await?;
}
Ok(())
}
pub async fn report(&self, r: &JudgementReport, token: &str) -> Result<()> {
let b = &r.binding;
let mut s = self.database.session().await?;
let t = s.client.transaction().await?;
let e = self.bound(&t, b, Some(token)).await?;
let value = as_value(r)?;
let digest = hash(&serde_json::to_vec(&value).map_err(invalid)?);
if let Some(old) = e.get::<_, Option<String>>("report_digest") {
if old == digest {
return Ok(());
} else {
return Err(Error::Rejected);
}
}
Self::live(&e)?;
let m: JobManifest = as_wire(e.get("manifest"))?;
let md: Digest = e
.get::<_, String>("manifest_digest")
.try_into()
.map_err(invalid)?;
r.validate_for_manifest(&m, &md).map_err(invalid)?;
if r.compilation
.program
.as_ref()
.is_some_and(|p| p.bytes > m.compile_limits.file_bytes)
{
return Err(Error::Invalid);
}
if !matches!(r.verdict, Verdict::SystemError | Verdict::Cancelled)
&& (e.get::<_, String>("state") == "claimed"
|| (r.compilation.status == CompilationStatus::Succeeded
&& e.get::<_, String>("state") != "running"))
{
return Err(Error::Rejected);
}
if !r.cases.is_empty() {
let index = e
.get::<_, Option<i32>>("case_index")
.ok_or(Error::Rejected)? as usize;
if r.cases.len() > index + 1
|| (!matches!(r.verdict, Verdict::SystemError | Verdict::Cancelled)
&& r.cases.len() != index + 1)
{
return Err(Error::Rejected);
}
}
let vid = m.problem_version_id.as_uuid();
for (position, c) in r.cases.iter().enumerate() {
let verdict = serde_json::to_value(c.verdict).map_err(invalid)?;
t.execute("INSERT INTO case_results(attempt_id,case_id,evaluation_id,problem_version_id,position,verdict,cpu_time_us,memory_peak_bytes) VALUES($1,$2,$3,$4,$5,$6,$7,$8)",&[&b.attempt_id.as_uuid(),&c.case_id.as_uuid(),&b.evaluation_id.as_uuid(),&vid,&(position as i32),&verdict.as_str().ok_or(Error::Invalid)?,&c.cpu_time_us.map(|v|v as i64),&c.memory_peak_bytes.map(|v|v as i64)]).await?;
}
t.execute(
"UPDATE judge_attempts SET report=$2,report_digest=$3 WHERE id=$1",
&[&b.attempt_id.as_uuid(), &value, &digest],
)
.await?;
t.execute(
"UPDATE evaluations SET final_report=$2,final_report_digest=$3 WHERE id=$1",
&[&b.evaluation_id.as_uuid(), &value, &digest],
)
.await?;
let verdict = serde_json::to_value(r.verdict).map_err(invalid)?;
let code = serde_json::to_value(r.failure_code).map_err(invalid)?;
self.finish(
&t,
&e,
b,
verdict.as_str().ok_or(Error::Invalid)?,
code.as_str(),
r.cleanup_complete,
)
.await?;
t.commit().await?;
Ok(())
}
pub async fn cancel(&self, eid: Identifier) -> Result<()> {
let mut s = self.database.session().await?;
let t = s.client.transaction().await?;
let e = t
.query_opt(
"SELECT state,current_attempt_id FROM evaluations WHERE id=$1 FOR UPDATE",
&[&eid.as_uuid()],
)
.await?
.ok_or(Error::Rejected)?;
let state = e.get::<_, String>("state");
if state == "queued" {
t.execute("UPDATE evaluations SET state='cancelled',verdict='cancelled',failure_code=NULL,final_report=NULL,final_report_digest=NULL,cancel_requested_at=clock_timestamp(),updated_at=clock_timestamp() WHERE id=$1",&[&eid.as_uuid()]).await?;
} else if matches!(
state.as_str(),
"claimed" | "compiling" | "running" | "cancelling"
) {
t.execute("UPDATE evaluations SET state='cancelling',cancel_requested_at=coalesce(cancel_requested_at,clock_timestamp()),updated_at=clock_timestamp() WHERE id=$1",&[&eid.as_uuid()]).await?;
t.execute("UPDATE judge_attempts SET revoked_at=coalesce(revoked_at,clock_timestamp()) WHERE id=$1",&[&e.get::<_,Option<Uuid>>("current_attempt_id")]).await?;
}
t.commit().await?;
Ok(())
}
pub async fn stopped(&self, r: &StoppedReport) -> Result<()> {
r.validate().map_err(invalid)?;
let b = &r.binding;
let mut s = self.database.session().await?;
let t = s.client.transaction().await?;
let e = self.bound(&t, b, None).await?;
if e.get::<_, String>("state") != "cancelling" {
if r.cleanup_complete && e.get::<_, Option<bool>>("cleanup_complete") == Some(true) {
return Ok(());
}
return Err(Error::Rejected);
}
if e.get::<_, Option<std::time::SystemTime>>("revoked_at")
.is_none()
{
return Err(Error::Rejected);
}
let code = e.get::<_, Option<String>>("failure_code");
self.finish(
&t,
&e,
b,
"system_error",
Some(code.as_deref().unwrap_or("lease_lost")),
r.cleanup_complete,
)
.await?;
t.commit().await?;
Ok(())
}
pub async fn pending_stops(&self, worker: Identifier) -> Result<Value> {
let s = self.database.session().await?;
let rows=s.client.query("SELECT e.id,a.id attempt_id,a.fencing_version,jsonb_array_length(a.manifest->'cases') case_count FROM evaluations e JOIN judge_attempts a ON a.id=e.current_attempt_id WHERE e.state='cancelling' AND a.worker_id=$1 ORDER BY e.updated_at,e.id LIMIT 16",&[&worker.as_uuid()]).await?;
Ok(Value::Array(rows.iter().map(|r|json!({"binding":LeaseBinding{evaluation_id:identifier(r.get("id")),attempt_id:identifier(r.get("attempt_id")),worker_id:worker,fencing_version:r.get::<_,i64>("fencing_version")as u64},"case_count":r.get::<_,i32>("case_count")})).collect()))
}
pub async fn recover(&self) -> Result<u64> {
let mut s = self.database.session().await?;
let t = s.client.transaction().await?;
let rows=t.query("SELECT e.id,e.current_attempt_id FROM evaluations e JOIN judge_attempts a ON a.id=e.current_attempt_id WHERE e.state IN ('claimed','compiling','running') AND (a.expires_at<=clock_timestamp() OR a.revoked_at IS NOT NULL) ORDER BY a.expires_at,e.id FOR UPDATE OF e SKIP LOCKED LIMIT 16",&[]).await?;
for r in &rows {
t.execute("UPDATE evaluations SET state='cancelling',failure_code='lease_lost',updated_at=clock_timestamp() WHERE id=$1",&[&r.get::<_,Uuid>("id")]).await?;
t.execute("UPDATE judge_attempts SET revoked_at=coalesce(revoked_at,clock_timestamp()) WHERE id=$1",&[&r.get::<_,Uuid>("current_attempt_id")]).await?;
}
// Re-notify queued rows if an earlier delivery was acknowledged without a claim.
// This is periodic bounded compensation, never execution authority or a busy loop.
let queued=t.query("SELECT e.id FROM evaluations e JOIN outbox o ON o.evaluation_id=e.id WHERE e.state='queued' AND e.not_before<=clock_timestamp() AND o.delivered_at<clock_timestamp()-interval '30 seconds' ORDER BY e.not_before,e.id FOR UPDATE OF e SKIP LOCKED LIMIT 16",&[]).await?;
for r in &queued {
t.execute("UPDATE outbox SET delivered_at=NULL,next_attempt_at=clock_timestamp() WHERE evaluation_id=$1",&[&r.get::<_,Uuid>("id")]).await?;
}
let count = (rows.len() + queued.len()) as u64;
t.commit().await?;
Ok(count)
}
}
+2 -1
View File
@@ -24,7 +24,8 @@
],
"cancelling": [
"cancelled",
"failed"
"failed",
"queued"
],
"finished": [],
"cancelled": [],
@@ -0,0 +1,11 @@
{
"schema_version": 1,
"kind": "judge.stopped",
"binding": {
"evaluation_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000001",
"attempt_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000007",
"worker_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000008",
"fencing_version": 1
},
"cleanup_complete": "true"
}
@@ -0,0 +1,12 @@
{
"schema_version": 1,
"kind": "judge.stopped",
"binding": {
"evaluation_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000001",
"attempt_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000007",
"worker_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000008",
"fencing_version": 1
},
"cleanup_complete": true,
"command": "/bin/sh"
}
@@ -0,0 +1,11 @@
{
"schema_version": 2,
"kind": "judge.stopped",
"binding": {
"evaluation_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000001",
"attempt_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000007",
"worker_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000008",
"fencing_version": 1
},
"cleanup_complete": true
}
@@ -0,0 +1,11 @@
{
"schema_version": 1,
"kind": "judge.stopped",
"binding": {
"evaluation_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000001",
"attempt_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000007",
"worker_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000008",
"fencing_version": 1
},
"cleanup_complete": false
}
+11
View File
@@ -0,0 +1,11 @@
{
"schema_version": 1,
"kind": "judge.stopped",
"binding": {
"evaluation_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000001",
"attempt_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000007",
"worker_id": "aaaaaaaa-aaaa-4aaa-8aaa-000000000008",
"fencing_version": 1
},
"cleanup_complete": true
}