feat(submissions): query bounded anonymous performance snapshots
This commit is contained in:
@@ -20,6 +20,7 @@ from kf_oj.infra.coordinator import CoordinatorControl
|
||||
from kf_oj.infra.db.session import build_engine, build_session_factory
|
||||
from kf_oj.modules.identity.service import IdentityService
|
||||
from kf_oj.modules.problems.service import ProblemService
|
||||
from kf_oj.modules.submissions.performance import SubmissionPerformanceQueries
|
||||
from kf_oj.modules.submissions.queries import SubmissionQueries
|
||||
from kf_oj.modules.submissions.service import SubmissionService
|
||||
from kf_oj.modules.submissions.worker_access import WorkerAccess
|
||||
@@ -42,6 +43,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app.state.identity = IdentityService(
|
||||
app.state.sessions, lifetime_seconds=settings.session_lifetime_seconds
|
||||
)
|
||||
app.state.submission_performance = SubmissionPerformanceQueries(app.state.sessions)
|
||||
artifacts = None
|
||||
if settings.artifact_root is not None:
|
||||
store = ArtifactStore(
|
||||
@@ -77,6 +79,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app.state.worker_access = None
|
||||
app.state.submissions = None
|
||||
app.state.submission_queries = None
|
||||
app.state.submission_performance = None
|
||||
if engine is not None:
|
||||
await engine.dispose()
|
||||
if store is not None:
|
||||
|
||||
@@ -169,6 +169,12 @@ class EvaluationPerformance(Base):
|
||||
CheckConstraint("report_digest ~ '^[0-9a-f]{64}$'", name="report_digest"),
|
||||
CheckConstraint("cpu_time_us IS NULL OR cpu_time_us >= 0", name="cpu"),
|
||||
CheckConstraint("memory_peak_bytes IS NULL OR memory_peak_bytes >= 0", name="memory"),
|
||||
Index(
|
||||
"ix_performance_group_watermark",
|
||||
"comparison_group",
|
||||
text("finished_at DESC"),
|
||||
text("evaluation_id DESC"),
|
||||
),
|
||||
Index(
|
||||
"ix_performance_cpu_time_us_cohort",
|
||||
"comparison_group",
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Read-only, bounded anonymous cohorts from Coordinator's immutable projection."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from kf_oj.contracts.judging import I64_MAX
|
||||
from kf_oj.contracts.judging_versions import decode_report
|
||||
from kf_oj.contracts.submission_performance import (
|
||||
MAX_PERFORMANCE_SAMPLES,
|
||||
MIN_PERFORMANCE_SAMPLES,
|
||||
PerformanceBucket,
|
||||
PerformanceMetric,
|
||||
SubmissionPerformance,
|
||||
)
|
||||
from kf_oj.modules.identity.public import Principal
|
||||
from kf_oj.modules.submissions.models import Evaluation, EvaluationPerformance, Submission
|
||||
from kf_oj.modules.submissions.public import SubmissionUnavailable
|
||||
|
||||
CACHE_TTL_SECONDS = 30
|
||||
CACHE_CAPACITY = 128
|
||||
HISTOGRAM_BUCKETS = 24
|
||||
|
||||
|
||||
def metric(unit, current, rows=(), *, reason=None) -> PerformanceMetric:
|
||||
"""Integer arithmetic throughout; conversion for display belongs to the client."""
|
||||
truncated = len(rows) > MAX_PERFORMANCE_SAMPLES
|
||||
rows = rows[:MAX_PERFORMANCE_SAMPLES]
|
||||
count = len(rows)
|
||||
base = dict(
|
||||
unit=unit,
|
||||
current=None if current is None else str(current),
|
||||
sample_count=count,
|
||||
window_truncated=truncated,
|
||||
oldest_sample_at=rows[-1][1] if rows else None,
|
||||
)
|
||||
if reason is None:
|
||||
reason = (
|
||||
"measurement_missing"
|
||||
if current is None
|
||||
else ("insufficient_samples" if count < MIN_PERFORMANCE_SAMPLES else None)
|
||||
)
|
||||
if reason is not None:
|
||||
return PerformanceMetric(
|
||||
**base,
|
||||
available=False,
|
||||
reason=reason,
|
||||
strictly_slower_count=None,
|
||||
tied_count=None,
|
||||
buckets=(),
|
||||
)
|
||||
values = [row[0] for row in rows]
|
||||
end = max(1, current, max(values))
|
||||
step = (end + HISTOGRAM_BUCKETS - 1) // HISTOGRAM_BUCKETS
|
||||
# Last upper bound includes the maximum, including I64_MAX. No overflow/padding.
|
||||
boundaries = list(range(0, end, step)) + [end]
|
||||
counts = [0] * (len(boundaries) - 1)
|
||||
for value in values:
|
||||
counts[min(value // step, len(counts) - 1)] += 1
|
||||
return PerformanceMetric(
|
||||
**base,
|
||||
available=True,
|
||||
reason=None,
|
||||
strictly_slower_count=sum(value > current for value in values),
|
||||
tied_count=sum(value == current for value in values),
|
||||
buckets=tuple(
|
||||
PerformanceBucket(
|
||||
start=str(start),
|
||||
end=str(stop),
|
||||
upper_inclusive=index == len(counts) - 1,
|
||||
count=counts[index],
|
||||
)
|
||||
for index, (start, stop) in enumerate(zip(boundaries, boundaries[1:], strict=False))
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SubmissionPerformanceQueries:
|
||||
def __init__(self, sessions: async_sessionmaker):
|
||||
self.sessions = sessions
|
||||
# No queued aggregate workload. At most one bounded in-memory row cohort per process.
|
||||
self._slot = asyncio.Lock()
|
||||
# Cache contains small immutable aggregate DTOs, never 10,000-row arrays.
|
||||
self._cache: OrderedDict[tuple, tuple[float, SubmissionPerformance]] = OrderedDict()
|
||||
|
||||
async def detail(self, principal: Principal, submission_id: UUID) -> SubmissionPerformance:
|
||||
if self._slot.locked():
|
||||
raise ValueError("performance query capacity unavailable")
|
||||
async with self._slot:
|
||||
try:
|
||||
async with asyncio.timeout(3), self.sessions() as session, session.begin():
|
||||
await session.execute(
|
||||
text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY")
|
||||
)
|
||||
await session.execute(text("SET LOCAL statement_timeout='2s'"))
|
||||
await session.execute(text("SET LOCAL lock_timeout='500ms'"))
|
||||
# Ownership checked even on a cache hit. Session authentication is upstream.
|
||||
row = (
|
||||
await session.execute(
|
||||
select(Submission, Evaluation, EvaluationPerformance)
|
||||
.join(Evaluation, Evaluation.submission_id == Submission.id)
|
||||
.outerjoin(
|
||||
EvaluationPerformance,
|
||||
EvaluationPerformance.evaluation_id == Evaluation.id,
|
||||
)
|
||||
.where(
|
||||
Submission.id == submission_id,
|
||||
Submission.account_id == principal.account_id,
|
||||
)
|
||||
)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
raise SubmissionUnavailable("submission unavailable")
|
||||
submission, evaluation, projection = row
|
||||
as_of = await session.scalar(text("SELECT clock_timestamp()"))
|
||||
accepted = (
|
||||
evaluation.state == "finished"
|
||||
and evaluation.verdict == "accepted"
|
||||
and evaluation.failure_code is None
|
||||
)
|
||||
if projection is not None and (
|
||||
not accepted
|
||||
or projection.attempt_id != evaluation.current_attempt_id
|
||||
or projection.report_digest != evaluation.final_report_digest
|
||||
):
|
||||
raise ValueError("stored performance binding invalid")
|
||||
cpu = memory = None
|
||||
reason = None if accepted else "not_accepted"
|
||||
if projection is not None:
|
||||
cpu, memory = projection.cpu_time_us, projection.memory_peak_bytes
|
||||
elif accepted:
|
||||
# A single owned legacy report, never a full-table JSON scan or promotion.
|
||||
report = decode_report(
|
||||
json.dumps(evaluation.final_report, ensure_ascii=False).encode()
|
||||
)
|
||||
if (
|
||||
report.binding.evaluation_id != evaluation.id
|
||||
or report.binding.attempt_id != evaluation.current_attempt_id
|
||||
or report.verdict.value != "accepted"
|
||||
or not report.cleanup_complete
|
||||
):
|
||||
raise ValueError("stored result binding invalid")
|
||||
cpu, memory = report.run_cpu_time_us, report.run_memory_peak_bytes
|
||||
reason = "environment_unverified"
|
||||
if any(v is not None and not 0 <= v <= I64_MAX for v in (cpu, memory)):
|
||||
raise ValueError("stored measurement out of range")
|
||||
base = dict(
|
||||
submission_id=submission.id,
|
||||
problem_version_id=submission.problem_version_id,
|
||||
language=submission.language,
|
||||
as_of=as_of,
|
||||
comparison_group=None
|
||||
if projection is None
|
||||
else projection.comparison_group,
|
||||
)
|
||||
if projection is None:
|
||||
return SubmissionPerformance(
|
||||
**base,
|
||||
cpu=metric("cpu_microseconds", cpu, reason=reason),
|
||||
memory=metric("bytes", memory, reason=reason),
|
||||
)
|
||||
group = projection.comparison_group
|
||||
watermark = (
|
||||
await session.execute(
|
||||
select(
|
||||
EvaluationPerformance.finished_at,
|
||||
EvaluationPerformance.evaluation_id,
|
||||
)
|
||||
.where(EvaluationPerformance.comparison_group == group)
|
||||
.order_by(
|
||||
EvaluationPerformance.finished_at.desc(),
|
||||
EvaluationPerformance.evaluation_id.desc(),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).one()
|
||||
key = (submission.id, projection.report_digest, group, tuple(watermark))
|
||||
cached = self._cache.get(key)
|
||||
now = time.monotonic()
|
||||
if cached is not None and now - cached[0] < CACHE_TTL_SECONDS:
|
||||
self._cache.move_to_end(key)
|
||||
return cached[1]
|
||||
metrics = []
|
||||
for column, unit, current in [
|
||||
(EvaluationPerformance.cpu_time_us, "cpu_microseconds", cpu),
|
||||
(EvaluationPerformance.memory_peak_bytes, "bytes", memory),
|
||||
]:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(column, EvaluationPerformance.finished_at)
|
||||
.where(
|
||||
EvaluationPerformance.comparison_group == group,
|
||||
EvaluationPerformance.evaluation_id != evaluation.id,
|
||||
column.is_not(None),
|
||||
)
|
||||
.order_by(
|
||||
EvaluationPerformance.finished_at.desc(),
|
||||
EvaluationPerformance.evaluation_id.desc(),
|
||||
)
|
||||
.limit(MAX_PERFORMANCE_SAMPLES + 1)
|
||||
)
|
||||
).all()
|
||||
metrics.append(metric(unit, current, rows))
|
||||
result = SubmissionPerformance(**base, cpu=metrics[0], memory=metrics[1])
|
||||
self._cache[key] = (now, result)
|
||||
self._cache.move_to_end(key)
|
||||
while len(self._cache) > CACHE_CAPACITY:
|
||||
self._cache.popitem(last=False)
|
||||
return result
|
||||
except (SQLAlchemyError, TimeoutError) as error:
|
||||
raise ValueError("performance database unavailable") from error
|
||||
Reference in New Issue
Block a user