feat(messaging): deliver durable outbox events with publisher confirms
This commit is contained in:
+11
-1
@@ -18,7 +18,17 @@ make worker-check
|
|||||||
API 提供存活、真实登录/退出/当前用户、公开题目与有效 Worker 租约的清单/工件读取。
|
API 提供存活、真实登录/退出/当前用户、公开题目与有效 Worker 租约的清单/工件读取。
|
||||||
配置数据库后在 lifespan 内组合模块,配置缺失时业务端口明确不可用,存活接口仍可用。
|
配置数据库后在 lifespan 内组合模块,配置缺失时业务端口明确不可用,存活接口仍可用。
|
||||||
Worker 目前仅支持 `--check`,默认拒绝进入尚未实现的任务循环。
|
Worker 目前仅支持 `--check`,默认拒绝进入尚未实现的任务循环。
|
||||||
提交创建、判题投递/成绩和模型接入尚未实现。账号/题目/工件命令及限制见对应模块 README。
|
提交创建 API 在持久化源码后,同一事务写入初始评测和 outbox;按用户与幂等键
|
||||||
|
防止重复创建,待评测总量和个人待评测数有固定上限。
|
||||||
|
|
||||||
|
待投递事件使用独立进程 `uv run --locked python -m kf_oj.infra.messaging.publisher`;
|
||||||
|
配置 `KF_OJ_DATABASE_URL` 为专用平台角色,`KF_OJ_AMQP_URL` 为内部 broker。
|
||||||
|
发布器一次只锁定一个到期事件;使用持久队列、消息及 mandatory publisher confirm,
|
||||||
|
确认后才提交投递标记。锁过期允许重投,消息不是执行权,Coordinator claim 才是。
|
||||||
|
队列最多 64 条、32 KiB,拒绝溢出,消息有效期 10 分钟;broker 不可用采用有界退避,
|
||||||
|
不阻塞提交事务。日志仅固定错误代码,不打印源码、答案或连接口令。
|
||||||
|
后台每次投递最多 12 秒,关闭连接最多 2 秒,SIGTERM 在当前有界操作结束后退出。
|
||||||
|
账号/题目/工件命令及限制见对应模块 README,前端成绩读取与模型接入尚未实现。
|
||||||
|
|
||||||
Python 虚拟环境位于 `backend/.venv`,可在 `backend/` 直接使用 `uv sync --locked` 和 `uv run --locked`。
|
Python 虚拟环境位于 `backend/.venv`,可在 `backend/` 直接使用 `uv sync --locked` 和 `uv run --locked`。
|
||||||
数据库由 Alembic 唯一管理;业务模型在 `migrations/env.py` 中显式登记元数据。
|
数据库由 Alembic 唯一管理;业务模型在 `migrations/env.py` 中显式登记元数据。
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""Finite transactional outbox delivery; confirmations are notifications, not grades."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import aio_pika
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from kf_oj.contracts.judge import JudgeTaskNotification
|
||||||
|
from kf_oj.core.settings import Settings
|
||||||
|
from kf_oj.infra.db.session import build_engine, build_session_factory
|
||||||
|
|
||||||
|
QUEUE = "kf.judge.evaluations.v1"
|
||||||
|
QUEUE_ARGUMENTS = {
|
||||||
|
"x-max-length": 64,
|
||||||
|
"x-max-length-bytes": 32768,
|
||||||
|
"x-overflow": "reject-publish",
|
||||||
|
"x-message-ttl": 600000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OutboxRepository:
|
||||||
|
def __init__(self, sessions):
|
||||||
|
self.sessions = sessions
|
||||||
|
|
||||||
|
async def claim(self):
|
||||||
|
owner = uuid4()
|
||||||
|
async with self.sessions.begin() as s:
|
||||||
|
row = (
|
||||||
|
await s.execute(
|
||||||
|
text("""WITH candidate AS (
|
||||||
|
SELECT id FROM outbox
|
||||||
|
WHERE delivered_at IS NULL AND next_attempt_at<=clock_timestamp()
|
||||||
|
AND (locked_until IS NULL OR locked_until<=clock_timestamp())
|
||||||
|
ORDER BY next_attempt_at,id FOR UPDATE SKIP LOCKED LIMIT 1)
|
||||||
|
UPDATE outbox o SET lock_owner=:owner,
|
||||||
|
locked_until=clock_timestamp()+interval '15 seconds'
|
||||||
|
FROM candidate c WHERE o.id=c.id RETURNING o.id,o.evaluation_id"""),
|
||||||
|
{"owner": owner},
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
return None if row is None else (row.id, row.evaluation_id, owner)
|
||||||
|
|
||||||
|
async def confirmed(self, item):
|
||||||
|
async with self.sessions.begin() as s:
|
||||||
|
result = await s.execute(
|
||||||
|
text("""UPDATE outbox SET delivered_at=clock_timestamp(),
|
||||||
|
locked_until=NULL,lock_owner=NULL,last_error_code=NULL
|
||||||
|
WHERE id=:id AND lock_owner=:owner AND locked_until>clock_timestamp()"""),
|
||||||
|
{"id": item[0], "owner": item[2]},
|
||||||
|
)
|
||||||
|
return result.rowcount == 1
|
||||||
|
|
||||||
|
async def failed(self, item):
|
||||||
|
async with self.sessions.begin() as s:
|
||||||
|
await s.execute(
|
||||||
|
text("""UPDATE outbox SET failures=least(failures+1,100),
|
||||||
|
next_attempt_at=clock_timestamp()+
|
||||||
|
least(power(2,least(failures+1,6)),60)*interval '1 second',
|
||||||
|
locked_until=NULL,lock_owner=NULL,last_error_code='broker_unavailable'
|
||||||
|
WHERE id=:id AND lock_owner=:owner AND locked_until>clock_timestamp()"""),
|
||||||
|
{"id": item[0], "owner": item[2]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmedBroker:
|
||||||
|
def __init__(self, url):
|
||||||
|
self.url = url
|
||||||
|
self.connection = None
|
||||||
|
self.channel = None
|
||||||
|
|
||||||
|
async def open(self):
|
||||||
|
async with asyncio.timeout(10):
|
||||||
|
await self._open()
|
||||||
|
|
||||||
|
async def _open(self):
|
||||||
|
# Use a finite connect, not a reconnect loop holding an outbox lock.
|
||||||
|
self.connection = await aio_pika.connect(self.url, timeout=5, heartbeat=30)
|
||||||
|
self.channel = await self.connection.channel(publisher_confirms=True, on_return_raises=True)
|
||||||
|
await self.channel.declare_queue(QUEUE, durable=True, arguments=QUEUE_ARGUMENTS, timeout=5)
|
||||||
|
|
||||||
|
async def publish(self, item):
|
||||||
|
# The complete operation ends before the database claim expires.
|
||||||
|
async with asyncio.timeout(12):
|
||||||
|
await self._publish(item)
|
||||||
|
|
||||||
|
async def _publish(self, item):
|
||||||
|
if self.channel is None or self.channel.is_closed:
|
||||||
|
await self.open()
|
||||||
|
body = (
|
||||||
|
JudgeTaskNotification(schema_version=1, kind="judge.evaluation", evaluation_id=item[1])
|
||||||
|
.model_dump_json()
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
assert len(body) <= 512
|
||||||
|
confirmation = await self.channel.default_exchange.publish(
|
||||||
|
aio_pika.Message(
|
||||||
|
body,
|
||||||
|
content_type="application/json",
|
||||||
|
delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
|
||||||
|
message_id=str(item[0]),
|
||||||
|
),
|
||||||
|
routing_key=QUEUE,
|
||||||
|
mandatory=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
from pamqp.commands import Basic
|
||||||
|
|
||||||
|
if not isinstance(confirmation, Basic.Ack):
|
||||||
|
raise RuntimeError("notification not confirmed")
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
if self.connection is not None and not self.connection.is_closed:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.connection.close(), timeout=2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.connection = self.channel = None
|
||||||
|
|
||||||
|
|
||||||
|
async def run(settings: Settings, stop: asyncio.Event):
|
||||||
|
if settings.amqp_url is None:
|
||||||
|
raise ValueError("AMQP configuration required")
|
||||||
|
engine = build_engine(settings)
|
||||||
|
repository = OutboxRepository(build_session_factory(engine))
|
||||||
|
broker = ConfirmedBroker(settings.amqp_url.get_secret_value())
|
||||||
|
logger = logging.getLogger("kf_oj.outbox")
|
||||||
|
# Client exceptions may include connection URIs; suppress transport-library logging.
|
||||||
|
for name in ("aio_pika", "aiormq", "pamqp"):
|
||||||
|
logging.getLogger(name).disabled = True
|
||||||
|
try:
|
||||||
|
async with engine.connect() as c:
|
||||||
|
allowed = await c.scalar(
|
||||||
|
text("""SELECT NOT r.rolsuper AND NOT r.rolbypassrls
|
||||||
|
AND pg_has_role(current_user,'kf_oj_platform','MEMBER')
|
||||||
|
AND NOT has_column_privilege(current_user,'evaluations','state','UPDATE')
|
||||||
|
FROM pg_roles r WHERE r.rolname=current_user""")
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
raise ValueError("dedicated platform database role required")
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
item = await repository.claim()
|
||||||
|
if item is not None:
|
||||||
|
try:
|
||||||
|
await broker.publish(item)
|
||||||
|
await repository.confirmed(item)
|
||||||
|
except Exception:
|
||||||
|
await repository.failed(item)
|
||||||
|
await broker.close()
|
||||||
|
logger.warning("outbox broker unavailable")
|
||||||
|
except Exception:
|
||||||
|
logger.warning("outbox database unavailable")
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(stop.wait(), timeout=1)
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await broker.close()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
async def entry():
|
||||||
|
stop = asyncio.Event()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
loop.add_signal_handler(sig, stop.set)
|
||||||
|
await run(Settings(), stop)
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(entry())
|
||||||
|
except Exception:
|
||||||
|
raise SystemExit("outbox unavailable") from None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Real private PostgreSQL/RabbitMQ control plane, no user program execution."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import aio_pika
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
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
|
||||||
|
from kf_oj.infra.messaging.publisher import QUEUE, ConfirmedBroker, OutboxRepository
|
||||||
|
|
||||||
|
|
||||||
|
def test_actual_confirm_duplicate_lock_recovery_and_broker_failure(
|
||||||
|
postgres_url, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
asyncio.run(outbox_acceptance(postgres_url, tmp_path, monkeypatch))
|
||||||
|
|
||||||
|
|
||||||
|
async def outbox_acceptance(postgres_url, tmp_path, monkeypatch):
|
||||||
|
config = os.environ.get("KF_OJ_TEST_AMQP_CONFIG")
|
||||||
|
if config is None:
|
||||||
|
pytest.skip("private RabbitMQ control acceptance requires KF_OJ_TEST_AMQP_CONFIG")
|
||||||
|
url = json.loads(Path(config).read_text())["amqp_url"]
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
assert parsed.hostname == "127.0.0.1" and parsed.port == 26572
|
||||||
|
_, 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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
broker = ConfirmedBroker(url)
|
||||||
|
connection = await aio_pika.connect(url, timeout=5)
|
||||||
|
try:
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
owner = await app.state.identity.create_account(
|
||||||
|
"outbox_test", secrets.token_urlsafe(24)
|
||||||
|
)
|
||||||
|
version = await app.state.problems.import_directory(tmp_path / "import")
|
||||||
|
created = await app.state.submissions.create(
|
||||||
|
owner,
|
||||||
|
CreateSubmission(
|
||||||
|
problem_version_id=version,
|
||||||
|
language="c17",
|
||||||
|
idempotency_key=uuid4(),
|
||||||
|
source="synthetic control event; not compiled",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
repo = OutboxRepository(app.state.sessions)
|
||||||
|
claimed = await asyncio.gather(repo.claim(), repo.claim())
|
||||||
|
assert sum(v is not None for v in claimed) == 1
|
||||||
|
item = next(v for v in claimed if v is not None)
|
||||||
|
assert item[1] == created.evaluation_id
|
||||||
|
# Kill a separate real publisher process after broker confirm, before DB mark.
|
||||||
|
code = """import asyncio,json,sys,signal
|
||||||
|
from uuid import UUID
|
||||||
|
from pathlib import Path
|
||||||
|
from kf_oj.infra.messaging.publisher import ConfirmedBroker
|
||||||
|
async def main():
|
||||||
|
item=tuple(UUID(x) for x in json.loads(sys.stdin.readline()))
|
||||||
|
broker=ConfirmedBroker(json.loads(Path(sys.argv[1]).read_text())["amqp_url"])
|
||||||
|
await broker.publish(item)
|
||||||
|
print("CONFIRMED",flush=True)
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
asyncio.run(main())
|
||||||
|
"""
|
||||||
|
child = subprocess.Popen(
|
||||||
|
[sys.executable, "-c", code, config],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
child.stdin.write(json.dumps([str(x) for x in item]) + "\n")
|
||||||
|
child.stdin.flush()
|
||||||
|
assert (
|
||||||
|
await asyncio.wait_for(asyncio.to_thread(child.stdout.readline), 10)
|
||||||
|
== "CONFIRMED\n"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
child.kill()
|
||||||
|
await asyncio.to_thread(child.wait, 5)
|
||||||
|
child.stdin.close()
|
||||||
|
child.stdout.close()
|
||||||
|
child.stderr.close()
|
||||||
|
assert child.returncode == -9
|
||||||
|
channel = await connection.channel()
|
||||||
|
queue = await channel.get_queue(QUEUE)
|
||||||
|
first = await queue.get(timeout=5)
|
||||||
|
assert json.loads(first.body) == {
|
||||||
|
"schema_version": 1,
|
||||||
|
"kind": "judge.evaluation",
|
||||||
|
"evaluation_id": str(created.evaluation_id),
|
||||||
|
}
|
||||||
|
assert len(first.body) <= 512 and first.delivery_mode == 2
|
||||||
|
# A publisher lost after confirm cannot mark DB; actual DB clock lock expires.
|
||||||
|
assert await repo.claim() is None
|
||||||
|
await asyncio.sleep(15.1)
|
||||||
|
retry = await repo.claim()
|
||||||
|
assert retry[:2] == item[:2] and retry[2] != item[2]
|
||||||
|
assert not await repo.confirmed(item)
|
||||||
|
await broker.publish(retry)
|
||||||
|
assert await repo.confirmed(retry)
|
||||||
|
second = await queue.get(timeout=5)
|
||||||
|
assert second.body == first.body and second.message_id == first.message_id
|
||||||
|
await first.ack()
|
||||||
|
await second.ack()
|
||||||
|
assert await repo.claim() is None
|
||||||
|
async with app.state.sessions() as s:
|
||||||
|
assert (
|
||||||
|
await s.scalar(
|
||||||
|
text("SELECT count(*) FROM outbox WHERE delivered_at IS NOT NULL")
|
||||||
|
)
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
# A second transaction succeeds even when a separate broker is unavailable.
|
||||||
|
await app.state.submissions.create(
|
||||||
|
owner,
|
||||||
|
CreateSubmission(
|
||||||
|
problem_version_id=version,
|
||||||
|
language="c17",
|
||||||
|
idempotency_key=uuid4(),
|
||||||
|
source="broker failure synthetic event",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
failed = await repo.claim()
|
||||||
|
unavailable = ConfirmedBroker(url.replace(":26572/", ":26579/"))
|
||||||
|
try:
|
||||||
|
with pytest.raises((ConnectionError, OSError)):
|
||||||
|
await unavailable.publish(failed)
|
||||||
|
finally:
|
||||||
|
await unavailable.close()
|
||||||
|
await repo.failed(failed)
|
||||||
|
assert await repo.claim() is None
|
||||||
|
async with app.state.sessions() as s:
|
||||||
|
row = (
|
||||||
|
await s.execute(
|
||||||
|
text("""SELECT failures,last_error_code,
|
||||||
|
next_attempt_at>clock_timestamp() deferred,delivered_at IS NULL pending,
|
||||||
|
lock_owner IS NULL unlocked FROM outbox WHERE id=:id"""),
|
||||||
|
{"id": failed[0]},
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
assert tuple(row) == (1, "broker_unavailable", True, True, True)
|
||||||
|
# Preserve the broker-failure row for inspection while testing another crash window.
|
||||||
|
async with app.state.sessions.begin() as s:
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE outbox SET next_attempt_at=clock_timestamp()+interval '1 hour' "
|
||||||
|
"WHERE id=:id"
|
||||||
|
),
|
||||||
|
{"id": failed[0]},
|
||||||
|
)
|
||||||
|
before = await app.state.submissions.create(
|
||||||
|
owner,
|
||||||
|
CreateSubmission(
|
||||||
|
problem_version_id=version,
|
||||||
|
language="c17",
|
||||||
|
idempotency_key=uuid4(),
|
||||||
|
source="before-confirm synthetic event",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
pending = await repo.claim()
|
||||||
|
assert pending[1] == before.evaluation_id
|
||||||
|
preconfirm = code.replace("await broker.publish(item)", "await broker.open()")
|
||||||
|
child = subprocess.Popen(
|
||||||
|
[sys.executable, "-c", preconfirm, config],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
child.stdin.write(json.dumps([str(x) for x in pending]) + "\n")
|
||||||
|
child.stdin.flush()
|
||||||
|
assert (
|
||||||
|
await asyncio.wait_for(asyncio.to_thread(child.stdout.readline), 10)
|
||||||
|
== "CONFIRMED\n"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
child.kill()
|
||||||
|
await asyncio.to_thread(child.wait, 5)
|
||||||
|
child.stdin.close()
|
||||||
|
child.stdout.close()
|
||||||
|
child.stderr.close()
|
||||||
|
assert child.returncode == -9
|
||||||
|
assert await queue.get(fail=False, timeout=5) is None
|
||||||
|
await asyncio.sleep(15.1)
|
||||||
|
retry = await repo.claim()
|
||||||
|
assert retry[:2] == pending[:2]
|
||||||
|
await broker.publish(retry)
|
||||||
|
assert await repo.confirmed(retry)
|
||||||
|
delivered = await queue.get(timeout=5)
|
||||||
|
assert json.loads(delivered.body)["evaluation_id"] == str(before.evaluation_id)
|
||||||
|
await delivered.ack()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await broker.close()
|
||||||
|
await connection.close()
|
||||||
Reference in New Issue
Block a user