From 886180cc203ac2d8a6c716f67dbc5b0cb2e2a464 Mon Sep 17 00:00:00 2001 From: Jay0130-a <309071492+Jay0130-a@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:46:00 +0800 Subject: [PATCH 1/2] fix(gateway): serialize inbound uploads so same-name writes do not collide --- .../infra/gateway/media/inbound_store.py | 29 +++++++++++++++++-- tests/unit/gateway/test_inbound_store.py | 25 ++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/octop/infra/gateway/media/inbound_store.py b/src/octop/infra/gateway/media/inbound_store.py index 66609aabb..b221db7da 100644 --- a/src/octop/infra/gateway/media/inbound_store.py +++ b/src/octop/infra/gateway/media/inbound_store.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import mimetypes import re import time @@ -405,6 +406,29 @@ async def _unique_inbound_path(workspace: BackendWorkspace, stored_name: str) -> raise OctopError(ErrorCode.INTERNAL_ERROR, f"cannot allocate unique path for {stored_name!r}") +# ``write_inbound`` allocates a free path and only then uploads to it. Both steps +# await, so two concurrent uploads whose timestamped names collide can observe the +# same free candidate and write to it — the second one silently wins and the first +# upload's bytes are lost. Serialise allocate+write per workspace so that every +# accepted upload keeps its own path and its own bytes. +_inbound_write_locks: dict[str, tuple[asyncio.AbstractEventLoop, asyncio.Lock]] = {} + + +def _inbound_write_lock(workspace: BackendWorkspace) -> asyncio.Lock: + """Per-workspace lock guarding inbound path allocation plus upload. + + The lock is rebuilt when the running event loop changes so a module-level + cache never hands a lock bound to a closed loop to a new one. + """ + loop = asyncio.get_running_loop() + key = str(workspace.workspace_dir) + entry = _inbound_write_locks.get(key) + if entry is None or entry[0] is not loop: + entry = (loop, asyncio.Lock()) + _inbound_write_locks[key] = entry + return entry[1] + + async def write_inbound( workspace: BackendWorkspace, data: bytes, @@ -421,8 +445,9 @@ async def write_inbound( if not Path(display_name).suffix: display_name = f"{display_name}{inbound_extension(display_name, normalized_type)}" stored_name = build_timestamped_inbound_name(display_name) - path = await _unique_inbound_path(workspace, stored_name) - await workspace.aupload_bytes(path, data) + async with _inbound_write_lock(workspace): + path = await _unique_inbound_path(workspace, stored_name) + await workspace.aupload_bytes(path, data) return InboundFile( path=path, filename=display_name, diff --git a/tests/unit/gateway/test_inbound_store.py b/tests/unit/gateway/test_inbound_store.py index 737e4792e..a8d52dabf 100644 --- a/tests/unit/gateway/test_inbound_store.py +++ b/tests/unit/gateway/test_inbound_store.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import re import tempfile @@ -103,3 +104,27 @@ async def test_write_inbound_flat_docx_path() -> None: ) assert stored.path.endswith(".docx") assert stored.filename == "report.docx" + + +@pytest.mark.asyncio +async def test_concurrent_same_name_uploads_keep_their_own_bytes() -> None: + """Uploads racing on one name must each keep their own path and bytes.""" + with tempfile.TemporaryDirectory() as ws_dir: + workspace = _workspace(ws_dir) + payloads = (b"first report", b"second report") + + stored = await asyncio.gather( + *( + write_inbound( + workspace, + payload, + filename="report.txt", + media_type="text/plain", + ) + for payload in payloads + ) + ) + + assert len({item.path for item in stored}) == 2 + written = [await read_inbound_bytes(workspace, item.path) for item in stored] + assert sorted(written) == sorted(payloads) From 21162c94d5a6676509f0f6b8d92bb75bb071c38d Mon Sep 17 00:00:00 2001 From: Jay0130-a <309071492+Jay0130-a@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:58:33 +0800 Subject: [PATCH 2/2] docs(changelog): record the inbound upload collision fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb939654d..a66bda567 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),版本号遵循 [语义化版本规范](https://semver.org/spec/v2.0.0.html)。 ## [Unreleased] +- 网关同时收到同名附件上传时不再丢文件:`write_inbound` 先分配路径再写入,同一秒内的两个请求会算出同一个 `{unix_ts}_{display}` 名字,各自都看到候选路径不存在而写入同一路径,只有后写的存活;现改为在按工作区缓存的 `asyncio.Lock` 下分配并写入,后来者会看到前一个刚写下的文件并走 `{stem}-{n}{suffix}` 兜底(Fixes #1889) - Telegram 通道可以限制允许的用户 ID(私聊和群聊都生效)。v1.0.2b7 的更新说明里已经写过这项,但合并时前端字段和 octop-gateway 1.0.1 依赖被丢掉了,这次补回 - 知识库文档上限设为 0(不限制)时,空库不再被当成已满,可以继续上传和新建文档 - 对话输入栏在专家配置了默认模型时显示该模型;与专家默认相同的选择不再写成会话粘性覆盖。专家的默认模型、知识库或连接器变更后,静默刷新会更新输入栏