导入、同步起草撞上剖析、起草任务时,说法按这次要做的事说下一步

复核 R3。导入目录、同步起草撞上剖析任务回 409 profile_busy 时,原话来自 busy_message,结尾固定是「请等这次剖析结束
(或先停止它)再开始新的剖析」;使用者这时是在导入、起草,不是要开始新的剖析,导入对话框原样显示这句。导入撞上起草
任务时同样说「再开始新的起草」(这是改造前就有的,同一类问题一起改了)。

- catalog_profile_jobs.busy_message / ensure_free 加 then(缺省 THEN_PROFILE「再开始新的剖析」,建剖析任务、重试照旧)。
- catalog_jobs.busy_message / sync_busy_message / ensure_draft_free 同样加 then(缺省 THEN_DRAFT「再开始新的起草」);
  ensure_idle 和 _lane_busy 按这次要做的事(kind)选:导入撞上什么都是「再导入」;同步起草撞上剖析是「再起草」,撞上
  起草仍是「再开始新的起草」(和建起草任务同一句话,test_catalog_draft_jobs 钉着这一点,没动那个文件)。
- 导入接口的说明补上撞上剖析任务回 profile_busy。

测试:剖析时导入、同步起草、再建剖析任务三句结尾各自对得上;队被剖析占着时(任务刚好在收尾)同样按这次要做的事说。
This commit is contained in:
ThearchyHelios
2026-10-15 19:12:48 +08:00
parent 7ee820f0f5
commit 4f0b60df03
4 changed files with 71 additions and 35 deletions
+3 -2
View File
@@ -346,8 +346,9 @@ async def preview_catalog_import(source_id: str, payload: CatalogImportIn,
@router.post("/{source_id}/catalog/import")
async def import_catalog(source_id: str, payload: CatalogImportIn, x_actor: str | None = Header(default=None),
session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""导入:规则同预览,逐表写入(乐观锁),返回同样形状的报告和每张表写入后的版本。有排队或进行中的起草任务
409 catalog_draft_busy;文件格式不对 422;没有表结构 409。"""
"""导入:规则同预览,逐表写入(乐观锁),返回同样形状的报告和每张表写入后的版本。有排队或进行中的起草任务(或正在
同步起草、导入)409 catalog_draft_busy,有剖析任务 409 profile_busy(说法的最后一句说「再导入」);文件格式不对 422;
没有表结构 409。"""
row, source = await _import_source(session, source_id)
try:
async with catalog_jobs.import_write(session, row):
+40 -22
View File
@@ -313,12 +313,26 @@ async def last_drafts(session: AsyncSession, source_id: str) -> dict[str, dict[s
# ==========================================================================
def busy_message(row: Any) -> str:
return f"数据源「{getattr(row, 'name', '')}」正在起草数据目录,请等这次起草结束(或先停止它)再开始新的起草"
#: 撞上起草时说法的最后一句(「请等这次起草结束{then}」)缺省是建起草任务的;导入目录、同步起草撞上起草、剖析时按这次要做
#: 的事说(_then)
THEN_DRAFT = "再开始新的起草"
def sync_busy_message(row: Any) -> str:
return f"数据源「{getattr(row, 'name', '')}」正在起草数据目录(同步起草),请等这次起草结束再开始新的起草"
def _then(kind: str, busy: str) -> str:
"""同步写操作(kind:import / sync_draft)撞上 busy(draft 起草、profile 剖析)时说法的最后一句:导入撞上什么都是
「再导入」;同步起草撞上起草是「再开始新的起草」(和建起草任务同一句话),撞上剖析是「再起草」——那时要做的是起草,
不是「开始新的剖析」。"""
if kind == "import":
return "再导入"
return THEN_DRAFT if busy == "draft" else "再起草"
def busy_message(row: Any, then: str = THEN_DRAFT) -> str:
return f"数据源「{getattr(row, 'name', '')}」正在起草数据目录,请等这次起草结束(或先停止它){then}"
def sync_busy_message(row: Any, then: str = THEN_DRAFT) -> str:
return f"数据源「{getattr(row, 'name', '')}」正在起草数据目录(同步起草),请等这次起草结束{then}"
def import_busy_message(row: Any) -> str:
@@ -334,33 +348,37 @@ def sync_drafting(source_id: str) -> bool:
return source_id in _SYNC
async def ensure_draft_free(session: AsyncSession, row: Any) -> None:
async def ensure_draft_free(session: AsyncSession, row: Any, *, then: str = THEN_DRAFT) -> None:
"""同种任务互斥:这个源没有在起草——没有排队或进行中的起草任务,也没有在同步起草。有就抛 DraftBusy。建起草任务、
重试之前调它。剖析任务不算:同源有剖析任务时起草任务照建,排在它后面。"""
重试之前调它。剖析任务不算:同源有剖析任务时起草任务照建,排在它后面。then 是说法最后一句要做的事。"""
if row.id in _SYNC:
raise DraftBusy(sync_busy_message(row))
raise DraftBusy(sync_busy_message(row, then))
if await active_job(session, row.id) is not None:
raise DraftBusy(busy_message(row))
raise DraftBusy(busy_message(row, then))
async def ensure_idle(session: AsyncSession, row: Any) -> None:
"""同步写操作(导入目录、同步起草)要求源空闲:没有在起草(DraftBusy,同 ensure_draft_free),也没有排队或进行中的
剖析任务(catalog_profile_jobs.ProfileBusy,说法里写明进行到第几张)。"""
async def ensure_idle(session: AsyncSession, row: Any, kind: str = "import") -> None:
"""同步写操作(kind:import 导入目录、sync_draft 同步起草)要求源空闲:没有在起草(DraftBusy,同 ensure_draft_free),
也没有排队或进行中的剖析任务(catalog_profile_jobs.ProfileBusy,说法里写明进行到第几张)。说法最后一句按 kind 说
这次要做的事(_then)。"""
from app.data import catalog_profile_jobs
await ensure_draft_free(session, row)
await catalog_profile_jobs.ensure_free(session, row)
await ensure_draft_free(session, row, then=_then(kind, "draft"))
await catalog_profile_jobs.ensure_free(session, row, then=_then(kind, "profile"))
def _lane_busy(row: Any, holder: runtime.LaneTicket) -> Exception:
"""库里查过没有任务、这个源的队却被占着(同步起草、导入目录正在做,或者一个任务刚好在收尾):按占着的是谁说。"""
def _lane_busy(row: Any, holder: runtime.LaneTicket, kind: str) -> Exception:
"""库里查过没有任务、这个源的队却被占着(同步起草、导入目录正在做,或者一个任务刚好在收尾):按占着的是谁说,最后
一句按这次要做的事(kind)说。"""
if holder.kind == "profile":
from app.data import catalog_profile_jobs
return catalog_profile_jobs.ProfileBusy(catalog_profile_jobs.busy_message(row, None))
return catalog_profile_jobs.ProfileBusy(catalog_profile_jobs.busy_message(row, None,
then=_then(kind, "profile")))
if holder.kind == "import":
return DraftBusy(import_busy_message(row))
return DraftBusy(sync_busy_message(row) if holder.kind == "sync_draft" else busy_message(row))
then = _then(kind, "draft")
return DraftBusy(sync_busy_message(row, then) if holder.kind == "sync_draft" else busy_message(row, then))
@contextlib.asynccontextmanager
@@ -371,7 +389,7 @@ async def _sync_write(row: Any, kind: str, check: Callable[[], Awaitable[None]])
try:
ticket = runtime.lanes.try_enter(row.id, kind)
except runtime.LaneBusy as e:
raise _lane_busy(row, e.holder) from e
raise _lane_busy(row, e.holder, kind) from e
try:
yield
finally:
@@ -379,12 +397,12 @@ async def _sync_write(row: Any, kind: str, check: Callable[[], Awaitable[None]])
async def _ensure_idle_but_sync(session: AsyncSession, row: Any) -> None:
"""同 ensure_idle,只是不看 _SYNC:同步起草自己刚登记在里面。"""
"""同 ensure_idle(…, "sync_draft"),只是不看 _SYNC:同步起草自己刚登记在里面。"""
from app.data import catalog_profile_jobs
if await active_job(session, row.id) is not None:
raise DraftBusy(busy_message(row))
await catalog_profile_jobs.ensure_free(session, row)
await catalog_profile_jobs.ensure_free(session, row, then=_then("sync_draft", "profile"))
@contextlib.asynccontextmanager
@@ -406,7 +424,7 @@ async def sync_draft(session: AsyncSession, row: Any) -> AsyncIterator[None]:
async def import_write(session: AsyncSession, row: Any) -> AsyncIterator[None]:
"""导入目录期间占着这个源的队:源上有排队或进行中的起草、剖析任务(或者正在同步起草、导入)时抛 DraftBusy /
ProfileBusy;导入期间新建的任务排在它后面。"""
async with _sync_write(row, "import", lambda: ensure_idle(session, row)):
async with _sync_write(row, "import", lambda: ensure_idle(session, row, "import")):
yield
@@ -791,7 +809,7 @@ manager = DraftJobs()
__all__ = ["ACTIVE", "BLANK_SAMPLE", "DraftBusy", "DraftFinished", "DraftJobs", "FINISHED", "GUIDANCE_MAX",
"INTERRUPTED_ERROR", "INTERRUPTED_WRITTEN", "MAX_TABLES", "NOTE_MAX", "NothingToRetry", "RETRYABLE",
"STOPPED_WRITTEN", "TASK_DONE", "TASK_STATUSES", "TextTooLong",
"STOPPED_WRITTEN", "TASK_DONE", "TASK_STATUSES", "THEN_DRAFT", "TextTooLong",
"active_job", "cancel", "check_model", "clean_text", "create_job", "ensure_draft_free", "ensure_idle",
"guidance_of", "import_busy_message", "import_write", "job_brief", "job_dict", "job_out", "last_drafts",
"latest_job", "load", "manager", "retry_tables", "save_guidance", "startup", "sync_busy_message", "sync_draft",
+18 -10
View File
@@ -390,9 +390,15 @@ def due_reason(meta: dict[str, Any] | None, notes: dict[str, Any] | None, last:
# ==========================================================================
#: 撞上剖析任务时说法的最后一句(「请等这次剖析结束(或先停止它){then}」)缺省是建剖析任务的;导入目录、同步起草撞上
#: 时各说各的(catalog_jobs):那时要做的是导入、起草,不是开始新的剖析
THEN_PROFILE = "再开始新的剖析"
def busy_message(row: Any, brief: dict[str, Any] | None, *, table: str | None = None,
position: int | None = None) -> str:
"""同源已有剖析任务时的说法:写明进行到第几张;从表详情点「剖析这张表」时再说这张表在不在那个任务里、排第几。"""
position: int | None = None, then: str = THEN_PROFILE) -> str:
"""同源已有剖析任务时的说法:写明进行到第几张;从表详情点「剖析这张表」时再说这张表在不在那个任务里、排第几;最后
一句按调用方要做的事说(then)。"""
name = getattr(row, "name", "") or ""
if brief is None:
text = f"数据源「{name}」正在剖析"
@@ -402,11 +408,13 @@ def busy_message(row: Any, brief: dict[str, Any] | None, *, table: str | None =
text = f"数据源「{name}」正在剖析:第 {min(brief['done'] + 1, brief['total'])} / {brief['total']} 张表"
if table is not None:
text += f",{table} 在这次剖析里,排第 {position} 张" if position else f",{table} 不在这次剖析里"
return text + "。请等这次剖析结束(或先停止它)再开始新的剖析"
return text + f"。请等这次剖析结束(或先停止它){then}"
async def ensure_free(session: AsyncSession, row: Any, tables: list[str] | None = None) -> None:
"""这个源没有排队或进行中的剖析任务;有就抛 ProfileBusy。只指定了一张表时,说法里带上这张表在不在那个任务里。"""
async def ensure_free(session: AsyncSession, row: Any, tables: list[str] | None = None, *,
then: str = THEN_PROFILE) -> None:
"""这个源没有排队或进行中的剖析任务;有就抛 ProfileBusy。只指定了一张表时,说法里带上这张表在不在那个任务里;then 是
说法的最后一句要做的事(见 busy_message)。"""
job = await active_job(session, row.id)
if job is None:
return
@@ -417,7 +425,7 @@ async def ensure_free(session: AsyncSession, row: Any, tables: list[str] | None
table = key
names = [r.table_name for r in await task_rows(session, job.id)]
position = names.index(key) + 1 if key in names else None
raise ProfileBusy(busy_message(row, brief, table=table, position=position))
raise ProfileBusy(busy_message(row, brief, table=table, position=position, then=then))
#: 挑出 0 张表时的说法
@@ -876,7 +884,7 @@ manager = ProfileJobs()
__all__ = ["INTERRUPTED_ERROR", "KEEP_JOBS", "MAX_TABLES", "NOTHING_AT_ALL", "NOTHING_NEEDED", "NothingToRetry",
"ProfileBusy", "ProfileFinished", "ProfileJobs", "ProfileNothing", "active_job", "busy_message", "cancel",
"create_job", "due_reason", "ensure_free", "job_brief", "job_dict", "job_out", "last_detail",
"last_profiles", "latest_job", "load", "load_task", "manager", "prune", "public_last", "retry_tables",
"startup", "task_detail", "task_row", "task_rows", "unsaved_error", "waiting_for"]
"ProfileBusy", "ProfileFinished", "ProfileJobs", "ProfileNothing", "THEN_PROFILE", "active_job",
"busy_message", "cancel", "create_job", "due_reason", "ensure_free", "job_brief", "job_dict", "job_out",
"last_detail", "last_profiles", "latest_job", "load", "load_task", "manager", "prune", "public_last",
"retry_tables", "startup", "task_detail", "task_row", "task_rows", "unsaved_error", "waiting_for"]
+10 -1
View File
@@ -186,11 +186,20 @@ async def test_import_and_sync_draft_are_409_while_profiling(client, make_source
job = (await _start(client, sid, ["channels"])).json()["job"]
await asyncio.wait_for(gate.entered.wait(), 10)
# 说法按这次要做的事说下一步:是在导入、起草,不是要开始新的剖析
r = await client.post(f"{_base(sid)}/import", json={"content": exported})
assert r.status_code == 409 and r.json()["code"] == "profile_busy", r.text
assert "正在剖析" in r.json()["detail"]
assert "正在剖析" in r.json()["detail"] and r.json()["detail"].endswith("请等这次剖析结束(或先停止它)再导入")
r = await client.post(f"{_base(sid)}/draft", json={"tables": ["gates"]})
assert r.status_code == 409 and r.json()["code"] == "profile_busy", r.text
assert r.json()["detail"].endswith("请等这次剖析结束(或先停止它)再起草")
again = await _start(client, sid, ["gates"])
assert again.json()["detail"].endswith("请等这次剖析结束(或先停止它)再开始新的剖析")
# 库里查过没有任务、队却被剖析占着(任务刚好在收尾)时同样按这次要做的事说
row, holder = SimpleNamespace(name="x"), SimpleNamespace(kind="profile")
assert str(catalog_jobs._lane_busy(row, holder, "import")).endswith("(或先停止它)再导入")
assert str(catalog_jobs._lane_busy(row, holder, "sync_draft")).endswith("(或先停止它)再起草")
assert str(catalog_jobs._lane_busy(row, SimpleNamespace(kind="draft"), "import")).endswith("(或先停止它)再导入")
gate.release.set()
await catalog_profile_jobs.manager.wait(job["id"], timeout=10)