feat(coordinator): dispatch versioned runtime leases and reports

This commit is contained in:
2026-10-09 15:18:08 +08:00
parent 4b8f255180
commit 53caafc157
10 changed files with 744 additions and 85 deletions
+7 -1
View File
@@ -331,10 +331,16 @@ NodeAdmission = v1.AdmissionPolicy | AdmissionPolicy | AdmissionRegistry
def admit_manifest(
policy: NodeAdmission | None, manifest: v1.JobManifest | JobManifest, attempt_number: int
) -> None:
if type(manifest) not in (v1.JobManifest, JobManifest):
raise ValueError("unsupported manifest model")
# Copied/constructed values must pass the original strict protocol validators too.
manifest = type(manifest).model_validate(manifest.model_dump(mode="json"))
if isinstance(policy, AdmissionRegistry):
policy.admit(manifest, attempt_number)
elif manifest.schema_version == 1 and type(policy) is v1.AdmissionPolicy:
policy.admit(manifest, attempt_number)
v1.AdmissionPolicy.model_validate(policy.model_dump(mode="json")).admit(
manifest, attempt_number
)
elif manifest.schema_version == 2 and type(policy) is AdmissionPolicy:
policy.admit(manifest, attempt_number)
else:
+10 -5
View File
@@ -27,8 +27,10 @@ ROOT = Path(__file__).resolve().parents[2]
@asynccontextmanager
async def fixture(postgres_url, tmp_path, monkeypatch):
_, policy = import_fixture(tmp_path / "import")
async def fixture(
postgres_url, tmp_path, monkeypatch, *, import_builder=import_fixture, node_policy=None
):
_, policy = import_builder(tmp_path / "import")
engine = create_async_engine(postgres_url)
platform_role(engine)
monkeypatch.setattr("kf_oj.api.app.build_engine", lambda _: engine)
@@ -66,7 +68,7 @@ async def fixture(postgres_url, tmp_path, monkeypatch):
database_url=coord_url,
workers=workers,
platform_token=platform,
admission=policy.model_dump(mode="json"),
admission=(node_policy or policy).model_dump(mode="json"),
lease_seconds=5,
recovery_seconds=1,
)
@@ -134,17 +136,20 @@ async def fixture(postgres_url, tmp_path, monkeypatch):
"cpu.max": "50000 100000",
}
async def create():
async def create(language="c17"):
return await app.state.submissions.create(
owner,
CreateSubmission(
problem_version_id=version,
language="c17",
language=language,
idempotency_key=uuid4(),
source="synthetic report fixture; never compiled on host",
),
)
client.fixture_app = app
client.fixture_account = owner
client.fixture_version = version
client.coordinator_unit = unit
client.coordinator_role = role
yield client, c, workers, platform, create
@@ -220,3 +220,18 @@ def test_registry_is_explicit_for_each_protocol_and_retains_legacy_configuration
raw["multi_runtime"]["prefetch"] = 2
with pytest.raises(ValueError):
Settings(_env_file=None, judge_admission=raw)
def test_generic_admission_revalidates_constructed_values_in_both_generations():
registry = fixture("judge-registry.valid", v2.AdmissionRegistry)
old = ROOT / "tests/fixtures/contracts/judge-manifest.valid.json"
legacy = v1.decode_wire(v1.JobManifest, old.read_bytes())
for manifest in (legacy, fixture("judge-manifest.valid-python313", v2.JobManifest)):
for wrong in (
manifest.model_copy(update={"schema_version": True}),
manifest.model_copy(update={"job_wall_time_us": 1}),
manifest.model_copy(update={"cases": ()}),
):
with pytest.raises(ValueError):
v2.admit_manifest(registry, wrong, 1)
v2.admit_manifest(registry, legacy, 1)
@@ -0,0 +1,241 @@
"""Actual finite Rust Coordinator/PG control acceptance; reports are synthetic, no execution."""
import asyncio
import copy
import hashlib
import json
from uuid import UUID, uuid4
import pytest
from test_coordinator import claim, fixture, manifest, notice, synthetic_report, worker_headers
from test_multi_runtime_versions import multi_import
from kf_oj.contracts import judging_v2 as v2
from kf_oj.contracts import judging_versions as versions
def report_v2(grant, task):
result = synthetic_report(grant, task)
result["schema_version"] = 2
language = v2.Language(task["language"])
kind = v2.runtime_contract(language)[3]
if kind == v2.ProgramKind.PYTHON:
files = [dict(path="main.py", artifact=task["source"])]
elif kind == v2.ProgramKind.JAVA:
files = [
dict(path=name, artifact=dict(digest="d" * 64, bytes=256))
for name in ("Helper.class", "Main$Inner.class", "Main.class")
]
else:
files = [dict(path="main", artifact=dict(digest="d" * 64, bytes=4096))]
result["compilation"]["program"] = dict(
kind=kind.value,
binding=grant["binding"],
source=task["source"],
toolchain=task["toolchain"],
files=files,
)
return result
def stage_v2(grant, language, sequence, stage="compiling", index=None):
return dict(
schema_version=2,
kind="judge.stage",
binding=grant["binding"],
sequence=sequence,
stage=stage,
case_index=index,
preparation_kind=v2.runtime_contract(v2.Language(language))[4].value,
)
def test_actual_versioned_claim_canonical_snapshot_stage_and_report(
postgres_url, tmp_path, monkeypatch
):
async def run():
async with fixture(postgres_url, tmp_path, monkeypatch, import_builder=multi_import) as (
client,
c,
workers,
_,
create,
):
for language in v2.Language:
created = await create(language.value)
grant = await claim(client, created, workers[0])
assert grant["schema_version"] == 2
task = await manifest(c, grant)
assert task["schema_version"] == 2 and task["language"] == language.value
parsed = versions.decode_manifest(json.dumps(task).encode())
canonical = versions.manifest_bytes(parsed)
assert hashlib.sha256(canonical).hexdigest() == grant["manifest_digest"]
async with client.fixture_app.state.sessions() as s:
version = await client.fixture_app.state.problems.for_submission(
s, created.problem_version_id
)
expected = version.manifest(
created.evaluation_id, created.submission_id, language, parsed.source
)
assert versions.manifest_bytes(expected) == canonical
headers = worker_headers(workers[0], grant)
bad = stage_v2(grant, language, 1)
bad["preparation_kind"] = (
"compile" if language == v2.Language.PYTHON313 else "syntax_check"
)
assert (
await client.post("/internal/stage", json=bad, headers=headers)
).status_code == 422
old = stage_v2(grant, language, 1)
old.update(schema_version=1)
old.pop("preparation_kind")
assert (
await client.post("/internal/stage", json=old, headers=headers)
).status_code == 422
for sequence, phase, index in (
(1, "compiling", None),
(2, "running", 0),
(3, "running", 1),
):
stage = stage_v2(grant, language, sequence, phase, index)
assert (
await client.post("/internal/stage", json=stage, headers=headers)
).status_code == 204
assert (
await client.post("/internal/stage", json=stage, headers=headers)
).status_code == 204
result = report_v2(grant, task)
for invalid in (
result | {"manifest_digest": "f" * 64},
result | {"binding": grant["binding"] | {"fencing_version": 2}},
):
assert (
await client.post("/internal/report", json=invalid, headers=headers)
).status_code in (409, 422)
changed = copy.deepcopy(result)
changed["compilation"]["program"]["toolchain"]["image_digest"] = "e" * 64
assert (
await client.post("/internal/report", json=changed, headers=headers)
).status_code == 422
assert (
await client.post("/internal/report", json=result, headers=headers)
).status_code == 204
assert (
await client.post("/internal/report", json=result, headers=headers)
).status_code == 204
final = await c.fetchval(
"SELECT final_report FROM evaluations WHERE id=$1", created.evaluation_id
)
assert json.loads(final) == result
assert (
await c.fetchval(
"SELECT state FROM evaluations WHERE id=$1", created.evaluation_id
)
== "finished"
)
assert (
await c.fetchval(
"SELECT count(*) FROM case_results WHERE evaluation_id=$1",
created.evaluation_id,
)
== 2
)
# No real program/Worker/Root executed; accepted is a synthetic protocol value only.
asyncio.run(run())
@pytest.mark.parametrize("kind", ["legacy", "identity", "budget"])
def test_actual_claim_refuses_missing_protocol_or_changed_capability_without_attempt(
postgres_url,
tmp_path,
monkeypatch,
kind,
):
from pathlib import Path
from kf_oj.contracts.judging import decode_wire
fixtures = Path(__file__).resolve().parents[2] / "tests/fixtures/contracts-v2"
registry = decode_wire(
v2.AdmissionRegistry, (fixtures / "judge-registry.valid.json").read_bytes()
)
raw = registry.model_dump(mode="json")
if kind == "identity":
raw["multi_runtime"]["capabilities"][2]["toolchain"]["image_digest"] = "e" * 64
elif kind == "budget":
raw["multi_runtime"]["capabilities"][2]["run_ceiling"]["memory_bytes"] = 32 * 1024 * 1024
policy = registry.legacy if kind == "legacy" else v2.AdmissionRegistry.model_validate(raw)
async def run():
async with fixture(
postgres_url,
tmp_path,
monkeypatch,
import_builder=multi_import,
node_policy=policy,
) as (client, c, workers, _, create):
created = await create("python313")
response = await client.post(
"/internal/claim",
json=notice(created.evaluation_id),
headers=worker_headers(workers[0]),
)
assert response.status_code == 422
assert await c.fetchval("SELECT count(*) FROM judge_attempts") == 0
assert await c.fetchval("SELECT state FROM evaluations") == "queued"
assert (await client.get("/health/ready")).status_code == 200
asyncio.run(run())
def test_actual_v2_cancel_requires_matching_stop_protocol_and_fence(
postgres_url, tmp_path, monkeypatch
):
async def run():
async with fixture(postgres_url, tmp_path, monkeypatch, import_builder=multi_import) as (
client,
c,
workers,
platform,
create,
):
created = await create("java21")
grant = await claim(client, created, workers[0])
assert (
await client.post(
"/internal/cancel",
json=notice(created.evaluation_id),
headers={"Authorization": "Bearer " + platform},
)
).status_code == 204
stopped = dict(
schema_version=2,
kind="judge.stopped",
binding=grant["binding"],
cleanup_complete=True,
)
headers = worker_headers(workers[0])
assert (
await client.post(
"/internal/stopped", json=stopped | {"schema_version": 1}, headers=headers
)
).status_code == 422
bad = stopped | {"binding": grant["binding"] | {"attempt_id": str(uuid4())}}
assert (
await client.post("/internal/stopped", json=bad, headers=headers)
).status_code == 409
assert (
await client.post("/internal/stopped", json=stopped, headers=headers)
).status_code == 204
assert await c.fetchval("SELECT state FROM evaluations") == "cancelled"
assert (
await c.fetchval(
"SELECT cleanup_complete FROM judge_attempts WHERE id=$1",
UUID(grant["binding"]["attempt_id"]),
)
is True
)
# This proves stop-report authority only; real JVM cleanup remains VM acceptance.
asyncio.run(run())
@@ -39,3 +39,270 @@ impl JudgementReport {
}
}
}
/// Operator explicitly declares accepted generations; legacy policies are never promoted.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum NodeAdmission {
Legacy(v1::AdmissionPolicy),
Multi(v2::AdmissionPolicy),
Registry(v2::AdmissionRegistry),
}
impl NodeAdmission {
pub fn validate(&self) -> v1::Validation {
match self {
Self::Legacy(policy) => policy.validate(),
Self::Multi(policy) => policy.validate(),
Self::Registry(policy) => policy.validate(),
}
}
pub fn admit(&self, manifest: &JobManifest, attempt_number: u32) -> v1::Validation {
self.validate()?;
match (self, manifest) {
(Self::Legacy(policy), JobManifest::V1(manifest)) => {
policy.admit(manifest, attempt_number)
}
(Self::Multi(policy), JobManifest::V2(manifest)) => {
policy.admit(manifest, attempt_number)
}
(Self::Registry(policy), manifest) => policy.admit(manifest, attempt_number),
_ => Err("node does not advertise this manifest protocol"),
}
}
fn capacity(&self) -> (u32, u32, u32) {
match self {
Self::Legacy(policy) => (
policy.max_concurrent_jobs,
policy.prefetch,
policy.max_retries,
),
Self::Multi(policy) => (
policy.max_concurrent_jobs,
policy.prefetch,
policy.max_retries,
),
Self::Registry(registry) => {
// Validation requires a nonempty registry and identical common node capacities.
if let Some(policy) = &registry.legacy {
(
policy.max_concurrent_jobs,
policy.prefetch,
policy.max_retries,
)
} else if let Some(policy) = &registry.multi_runtime {
(
policy.max_concurrent_jobs,
policy.prefetch,
policy.max_retries,
)
} else {
(0, 0, 0)
}
}
}
}
pub fn max_concurrent_jobs(&self) -> u32 {
self.capacity().0
}
pub fn prefetch(&self) -> u32 {
self.capacity().1
}
pub fn max_retries(&self) -> u32 {
self.capacity().2
}
}
macro_rules! versions {
($name:ident) => {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum $name {
V1(v1::$name),
V2(v2::$name),
}
};
}
versions!(LeaseGrant);
versions!(StageReport);
versions!(StoppedReport);
impl JobManifest {
pub fn version(&self) -> u16 {
match self {
Self::V1(_) => 1,
Self::V2(_) => 2,
}
}
pub fn cases(&self) -> &[v1::TestCase] {
match self {
Self::V1(m) => &m.cases,
Self::V2(m) => &m.cases,
}
}
pub fn compile_limits(&self) -> &v1::RunLimits {
match self {
Self::V1(m) => &m.compile_limits,
Self::V2(m) => &m.compile_limits,
}
}
pub fn problem_version_id(&self) -> v1::Identifier {
match self {
Self::V1(m) => m.problem_version_id,
Self::V2(m) => m.problem_version_id,
}
}
pub fn evaluation_id(&self) -> v1::Identifier {
match self {
Self::V1(m) => m.evaluation_id,
Self::V2(m) => m.evaluation_id,
}
}
}
impl LeaseGrant {
pub fn for_manifest(
manifest: &JobManifest,
binding: v1::LeaseBinding,
lease_token: v1::LeaseToken,
attempt_number: u32,
expires_at_unix_ms: u64,
manifest_digest: v1::Digest,
) -> Self {
match manifest {
JobManifest::V1(_) => Self::V1(v1::LeaseGrant {
schema_version: crate::SchemaVersion,
kind: v1::LeaseKind::Value,
binding,
lease_token,
attempt_number,
expires_at_unix_ms,
manifest_digest,
}),
JobManifest::V2(_) => Self::V2(v2::LeaseGrant {
schema_version: v2::SchemaVersion,
kind: v1::LeaseKind::Value,
binding,
lease_token,
attempt_number,
expires_at_unix_ms,
manifest_digest,
}),
}
}
}
/// Shared state-machine projection only; serialization retains the original wire value.
pub struct StageFields<'a> {
pub binding: &'a v1::LeaseBinding,
pub sequence: u64,
pub stage: v1::Stage,
pub case_index: Option<u16>,
}
impl StageReport {
pub fn fields(&self) -> StageFields<'_> {
match self {
Self::V1(r) => StageFields {
binding: &r.binding,
sequence: r.sequence,
stage: r.stage,
case_index: r.case_index,
},
Self::V2(r) => StageFields {
binding: &r.binding,
sequence: r.sequence,
stage: r.stage,
case_index: r.case_index,
},
}
}
pub fn validate_for_manifest(&self, manifest: &JobManifest) -> v1::Validation {
match (self, manifest) {
(Self::V1(r), JobManifest::V1(m)) => {
r.validate()?;
if r.binding.evaluation_id != m.evaluation_id {
return Err("stage belongs to another manifest");
}
Ok(())
}
(Self::V2(r), JobManifest::V2(m)) => r.validate_for_manifest(m),
_ => Err("stage protocol differs from authorized manifest"),
}
}
}
pub struct ReportFields<'a> {
pub binding: &'a v1::LeaseBinding,
pub verdict: v1::Verdict,
pub failure_code: Option<v1::FailureCode>,
pub cleanup_complete: bool,
pub compilation_status: v1::CompilationStatus,
pub cases: &'a [v1::CaseReport],
}
impl JudgementReport {
pub fn fields(&self) -> ReportFields<'_> {
match self {
Self::V1(r) => ReportFields {
binding: &r.binding,
verdict: r.verdict,
failure_code: r.failure_code,
cleanup_complete: r.cleanup_complete,
compilation_status: r.compilation.status,
cases: &r.cases,
},
Self::V2(r) => ReportFields {
binding: &r.binding,
verdict: r.verdict,
failure_code: r.failure_code,
cleanup_complete: r.cleanup_complete,
compilation_status: r.compilation.status,
cases: &r.cases,
},
}
}
pub fn validate_program_budget(&self, manifest: &JobManifest) -> v1::Validation {
let limits = manifest.compile_limits();
let fits = match self {
Self::V1(r) => r
.compilation
.program
.as_ref()
.is_none_or(|p| p.bytes <= limits.file_bytes),
Self::V2(r) => r.compilation.program.as_ref().is_none_or(|p| {
// Python reuses read-only source input, not a compiler output file.
p.kind == v2::ProgramKind::PythonSource
|| (p
.files
.iter()
.all(|f| f.artifact.bytes <= limits.file_bytes)
&& p.bytes() <= limits.workdir_bytes)
}),
};
if fits {
Ok(())
} else {
Err("compiled program exceeds output budget")
}
}
}
impl StoppedReport {
pub fn binding(&self) -> &v1::LeaseBinding {
match self {
Self::V1(r) => &r.binding,
Self::V2(r) => &r.binding,
}
}
pub fn cleanup_complete(&self) -> bool {
match self {
Self::V1(r) => r.cleanup_complete,
Self::V2(r) => r.cleanup_complete,
}
}
pub fn validate_for_manifest(&self, manifest: &JobManifest) -> v1::Validation {
match (self, manifest) {
(Self::V1(r), JobManifest::V1(_)) => r.validate()?,
(Self::V2(r), JobManifest::V2(_)) => r.validate()?,
_ => return Err("stop protocol differs from authorized manifest"),
}
if self.binding().evaluation_id != manifest.evaluation_id() {
return Err("stop belongs to another manifest");
}
Ok(())
}
}
@@ -187,3 +187,77 @@ fn registry_explicitly_admits_both_protocols_without_promoting_legacy_policy() {
wrong.multi_runtime.as_mut().unwrap().max_retries += 1;
assert!(wrong.validate().is_err());
}
#[test]
fn operator_admission_and_transport_dispatch_keep_generations_explicit() {
let original = fixtures().parent().unwrap().join("contracts");
let legacy_policy: versions::NodeAdmission =
v1::decode_wire(&fs::read(original.join("judge-admission.valid.json")).unwrap()).unwrap();
let multi_policy: versions::NodeAdmission = fixture("judge-admission.valid");
let registry: versions::NodeAdmission = fixture("judge-registry.valid");
let old_manifest: versions::JobManifest =
v1::decode_wire(&fs::read(original.join("judge-manifest.valid.json")).unwrap()).unwrap();
let new_manifest: versions::JobManifest = fixture("judge-manifest.valid-python313");
for policy in [&legacy_policy, &multi_policy, &registry] {
policy.validate().unwrap();
assert_eq!(policy.max_concurrent_jobs(), 1);
assert_eq!(policy.prefetch(), 1);
assert_eq!(policy.max_retries(), 2);
}
registry.admit(&old_manifest, 1).unwrap();
registry.admit(&new_manifest, 1).unwrap();
assert!(legacy_policy.admit(&new_manifest, 1).is_err());
assert!(multi_policy.admit(&old_manifest, 1).is_err());
let old_stage: versions::StageReport =
v1::decode_wire(&fs::read(original.join("judge-stage.valid.json")).unwrap()).unwrap();
let new_stage: versions::StageReport = fixture("judge-stage.valid-python313");
old_stage.validate_for_manifest(&old_manifest).unwrap();
new_stage.validate_for_manifest(&new_manifest).unwrap();
assert!(old_stage.validate_for_manifest(&new_manifest).is_err());
assert!(new_stage.validate_for_manifest(&old_manifest).is_err());
let old_stop: versions::StoppedReport =
v1::decode_wire(&fs::read(original.join("judge-stopped.valid.json")).unwrap()).unwrap();
let new_stop: versions::StoppedReport = fixture("judge-stopped.valid");
assert!(old_stop.validate_for_manifest(&new_manifest).is_err());
assert!(new_stop.validate_for_manifest(&old_manifest).is_err());
for manifest in [&old_manifest, &new_manifest] {
let legacy_grant: v1::LeaseGrant =
v1::decode_wire(&fs::read(original.join("judge-lease.valid.json")).unwrap()).unwrap();
let grant = versions::LeaseGrant::for_manifest(
manifest,
legacy_grant.binding,
legacy_grant.lease_token,
legacy_grant.attempt_number,
legacy_grant.expires_at_unix_ms,
legacy_grant.manifest_digest,
);
let value = serde_json::to_value(&grant).unwrap();
assert_eq!(
value["schema_version"].as_u64().unwrap(),
u64::from(manifest.version())
);
v1::decode_wire::<versions::LeaseGrant>(&serde_json::to_vec(&value).unwrap()).unwrap();
}
}
#[test]
fn typed_compiler_output_checks_individual_files_and_collection_storage() {
let report: versions::JudgementReport = fixture("judge-result.valid-java21");
let mut manifest: versions::JobManifest = fixture("judge-manifest.valid-java21");
report.validate_program_budget(&manifest).unwrap();
let versions::JobManifest::V2(m) = &mut manifest else {
unreachable!()
};
m.compile_limits.stdout_bytes = 32;
m.compile_limits.stderr_bytes = 32;
m.compile_limits.file_bytes = 63;
m.validate().unwrap();
assert!(report.validate_program_budget(&manifest).is_err());
let versions::JobManifest::V2(m) = &mut manifest else {
unreachable!()
};
m.compile_limits.file_bytes = 64;
m.compile_limits.workdir_bytes = 128;
m.validate().unwrap();
assert!(report.validate_program_budget(&manifest).is_err());
}
+4 -3
View File
@@ -8,6 +8,7 @@ use axum::{
response::{IntoResponse, Response},
routing::{get, post},
};
use kf_contracts::judging_versions::{JudgementReport, StageReport, StoppedReport};
use kf_contracts::{JudgeTaskNotification, judging::*};
use serde_json::{Value, json};
use std::{sync::Arc, time::Duration};
@@ -158,19 +159,19 @@ async fn progress(State(app): State<App>, h: HeaderMap, b: Bytes) -> ApiResult<J
}
async fn stage(State(app): State<App>, h: HeaderMap, b: Bytes) -> ApiResult<StatusCode> {
let r: StageReport = decode(&b)?;
bound_worker(&app, &h, &r.binding)?;
bound_worker(&app, &h, r.fields().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)?;
bound_worker(&app, &h, r.fields().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)?;
bound_worker(&app, &h, r.binding())?;
app.repository.stopped(&r).await?;
Ok(StatusCode::NO_CONTENT)
}
+3 -2
View File
@@ -1,5 +1,6 @@
//! Service-only secrets from a private local file; never format database/token values.
use kf_contracts::judging::{AdmissionPolicy, Identifier, LeaseToken};
use kf_contracts::judging::{Identifier, LeaseToken};
use kf_contracts::judging_versions::NodeAdmission;
use serde::Deserialize;
use std::{collections::HashSet, fs, io, net::SocketAddr, path::Path};
#[derive(Deserialize)]
@@ -15,7 +16,7 @@ pub struct Config {
pub database_url: String,
pub workers: Vec<WorkerIdentity>,
pub platform_token: LeaseToken,
pub admission: AdmissionPolicy,
pub admission: NodeAdmission,
pub lease_seconds: u32,
pub recovery_seconds: u32,
}
+106 -58
View File
@@ -1,6 +1,9 @@
//! Execution authority is applied under row locks, using database clock and fencing.
use crate::database::{Database, Error, Result};
use kf_contracts::{SchemaVersion, judging::*};
use kf_contracts::judging_versions::{
JobManifest, JudgementReport, LeaseGrant, NodeAdmission, StageReport, StoppedReport,
};
use kf_contracts::{SchemaVersion, judging::*, judging_v2 as v2};
use serde_json::{Value, json};
use sha2::{Digest as _, Sha256};
use tokio_postgres::{Row, Transaction};
@@ -8,7 +11,7 @@ use uuid::Uuid;
#[derive(Clone)]
pub struct Repository {
pub database: Database,
pub admission: AdmissionPolicy,
pub admission: NodeAdmission,
pub lease_seconds: u32,
}
pub fn identifier(v: Uuid) -> Identifier {
@@ -34,7 +37,7 @@ 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 h=t.query_opt("SELECT s.id,s.problem_version_id,s.language,s.source_digest,a.bytes,v.judging_schema_version,v.languages,v.language_configurations,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();
@@ -60,7 +63,16 @@ impl Repository {
},
});
}
let m = JobManifest {
let schema: i16 = h.get("judging_schema_version");
let source = ArtifactReference {
digest: h
.get::<_, String>("source_digest")
.try_into()
.map_err(invalid)?,
bytes: h.get::<_, i64>("bytes") as u64,
};
let m = if schema == 1 {
JobManifest::V1(kf_contracts::judging::JobManifest {
schema_version: SchemaVersion,
kind: ManifestKind::Value,
evaluation_id: identifier(eid),
@@ -68,20 +80,59 @@ impl Repository {
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,
},
source,
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,
})
} else if schema == 2 {
let configs: Vec<v2::LanguageConfiguration> = as_wire(
h.get::<_, Option<Value>>("language_configurations")
.ok_or(Error::Invalid)?,
)?;
let languages: Vec<v2::Language> = as_wire(h.get("languages"))?;
if configs.is_empty()
|| configs.len() > 4
|| configs.iter().map(|c| c.language).collect::<Vec<_>>() != languages
|| languages
.iter()
.collect::<std::collections::HashSet<_>>()
.len()
!= languages.len()
|| ["toolchain", "compile_limits", "run_limits"]
.iter()
.any(|key| h.get::<_, Option<Value>>(*key).is_some())
|| h.get::<_, Option<i64>>("job_wall_time_us").is_some()
{
return Err(Error::Invalid);
}
let language: v2::Language = as_wire(json!(h.get::<_, String>("language")))?;
let config = configs
.into_iter()
.find(|c| c.language == language)
.ok_or(Error::Invalid)?;
JobManifest::V2(v2::JobManifest {
schema_version: v2::SchemaVersion,
kind: ManifestKind::Value,
evaluation_id: identifier(eid),
submission_id: identifier(sid),
problem_version_id: identifier(vid),
language,
source,
cases,
comparison: Comparison::Value,
toolchain: config.toolchain,
compile_limits: config.compile_limits,
run_limits: config.run_limits,
job_wall_time_us: config.job_wall_time_us,
})
} else {
return Err(Error::Invalid);
};
m.validate().map_err(invalid)?;
// Canonicalization runs the selected generation's full semantic validators.
m.canonical_bytes().map_err(invalid)?;
Ok(m)
}
pub async fn claim(&self, eid: Identifier, worker: Identifier) -> Result<Option<LeaseGrant>> {
@@ -91,7 +142,7 @@ impl Repository {
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);
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")
@@ -101,7 +152,7 @@ impl Repository {
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) {
if active >= i64::from(self.admission.max_concurrent_jobs()) {
return Err(Error::Busy);
}
let m = self.manifest(&t, &e).await?;
@@ -113,23 +164,22 @@ impl Repository {
.get::<_, i64>("fencing_version")
.checked_add(1)
.ok_or(Error::Invalid)?;
let digest = hash(&manifest_bytes(&m).map_err(invalid)?);
let digest = hash(&m.canonical_bytes().map_err(invalid)?);
let row=t.query_one("INSERT INTO judge_attempts(id,evaluation_id,worker_id,number,fencing_version,lease_token_hash,expires_at,manifest,manifest_digest) VALUES($1,$2,$3,$4,$5,$6,clock_timestamp()+$7::bigint*interval '1 second',$8,$9) RETURNING floor(extract(epoch from expires_at)*1000)::bigint",&[&attempt,&eid.as_uuid(),&worker.as_uuid(),&number,&fence,&hash(token.as_bytes()),&i64::from(self.lease_seconds),&as_value(&m)?,&digest]).await?;
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 {
let grant = LeaseGrant::for_manifest(
&m,
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)?,
};
serde_json::from_value(json!(token)).map_err(invalid)?,
number as u32,
row.get::<_, i64>(0) as u64,
digest.try_into().map_err(invalid)?,
);
t.commit().await?;
Ok(Some(grant))
}
@@ -188,28 +238,30 @@ impl Repository {
)
}
pub async fn stage(&self, report: &StageReport, token: &str) -> Result<()> {
report.validate().map_err(invalid)?;
let b = &report.binding;
let fields = report.fields();
let b = fields.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 {
let m: JobManifest = as_wire(e.get("manifest"))?;
report.validate_for_manifest(&m).map_err(invalid)?;
let stage = match fields.stage {
Stage::Compiling => "compiling",
Stage::Running => "running",
};
let index = report.case_index.map(i32::from);
let index = fields.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
if fields.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 {
if fields.sequence as i64 != sequence.checked_add(1).ok_or(Error::Invalid)?
|| match fields.stage {
Stage::Compiling => e.get::<_, String>("state") != "claimed" || old_stage.is_some(),
Stage::Running => {
!matches!(
@@ -221,15 +273,14 @@ impl Repository {
{
return Err(Error::Rejected);
}
let m: JobManifest = as_wire(e.get("manifest"))?;
if index.is_some_and(|i| i as usize >= m.cases.len()) {
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),
&(fields.sequence as i64),
&stage,
&index,
],
@@ -255,7 +306,8 @@ impl Repository {
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 retries =
(e.get::<_, i32>("max_attempts") as u32).min(self.admission.max_retries() + 1);
let retry = !cancelled
&& clean
&& verdict == "system_error"
@@ -283,7 +335,8 @@ impl Repository {
Ok(())
}
pub async fn report(&self, r: &JudgementReport, token: &str) -> Result<()> {
let b = &r.binding;
let fields = r.fields();
let b = fields.binding;
let mut s = self.database.session().await?;
let t = s.client.transaction().await?;
let e = self.bound(&t, b, Some(token)).await?;
@@ -303,33 +356,27 @@ impl Repository {
.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)
r.validate_program_budget(&m).map_err(invalid)?;
if !matches!(fields.verdict, Verdict::SystemError | Verdict::Cancelled)
&& (e.get::<_, String>("state") == "claimed"
|| (r.compilation.status == CompilationStatus::Succeeded
|| (fields.compilation_status == CompilationStatus::Succeeded
&& e.get::<_, String>("state") != "running"))
{
return Err(Error::Rejected);
}
if !r.cases.is_empty() {
if !fields.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)
if fields.cases.len() > index + 1
|| (!matches!(fields.verdict, Verdict::SystemError | Verdict::Cancelled)
&& fields.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 vid = m.problem_version_id().as_uuid();
for (position, c) in fields.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?;
}
@@ -343,15 +390,15 @@ impl Repository {
&[&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)?;
let verdict = serde_json::to_value(fields.verdict).map_err(invalid)?;
let code = serde_json::to_value(fields.failure_code).map_err(invalid)?;
self.finish(
&t,
&e,
b,
verdict.as_str().ok_or(Error::Invalid)?,
code.as_str(),
r.cleanup_complete,
fields.cleanup_complete,
)
.await?;
t.commit().await?;
@@ -381,13 +428,14 @@ impl Repository {
Ok(())
}
pub async fn stopped(&self, r: &StoppedReport) -> Result<()> {
r.validate().map_err(invalid)?;
let b = &r.binding;
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?;
let m: JobManifest = as_wire(e.get("manifest"))?;
r.validate_for_manifest(&m).map_err(invalid)?;
if e.get::<_, String>("state") != "cancelling" {
if r.cleanup_complete && e.get::<_, Option<bool>>("cleanup_complete") == Some(true) {
if r.cleanup_complete() && e.get::<_, Option<bool>>("cleanup_complete") == Some(true) {
return Ok(());
}
return Err(Error::Rejected);
@@ -404,7 +452,7 @@ impl Repository {
b,
"system_error",
Some(code.as_deref().unwrap_or("lease_lost")),
r.cleanup_complete,
r.cleanup_complete(),
)
.await?;
t.commit().await?;
+5 -4
View File
@@ -1,6 +1,7 @@
//! Ordinary transport adapter. AMQP is a bounded wake-up; claim is sole authority.
use crate::pipeline::{ArtifactPort, Attempt};
use futures_lite::{StreamExt, future::block_on};
use kf_contracts::judging_versions::{JobManifest as VersionedManifest, NodeAdmission};
use kf_contracts::{JudgeTaskNotification, SchemaVersion, judging::*};
use kf_engine::submission::{StageFailure, judge_submission};
use kf_sandbox::{artifacts::StagePublication, factory::FactoryClient};
@@ -49,7 +50,7 @@ pub struct Config {
pub amqp_url: String,
pub socket: PathBuf,
pub staging: PathBuf,
pub admission: AdmissionPolicy,
pub admission: NodeAdmission,
}
impl Config {
pub fn load(path: &Path) -> io::Result<Self> {
@@ -85,8 +86,8 @@ impl Config {
}
if !c.socket.is_absolute()
|| !c.staging.is_absolute()
|| c.admission.max_concurrent_jobs != 1
|| c.admission.prefetch != 1
|| c.admission.max_concurrent_jobs() != 1
|| c.admission.prefetch() != 1
{
return Err(error());
}
@@ -292,7 +293,7 @@ fn execute(c: &Config, r: &Remote, g: LeaseGrant, stop: &AtomicBool) -> io::Resu
};
let m = artifacts.manifest()?;
c.admission
.admit(&m, g.attempt_number)
.admit(&VersionedManifest::V1(m.clone()), g.attempt_number)
.map_err(|_| error())?;
let cancel = AtomicBool::new(false);
let done = AtomicBool::new(false);