fix: P0-5 — upload-status rollback, streaming copy (LOCAL only), user re-verify, honest lock

B1: `_mark_upload_failed_and_raise` now commits on a separate session
  - Helper takes `request + upload_id`, opens a fresh session from
    `request.app.state.session_factory` and commits there before raising.
  - Closes the named-lock connection-pool leak Codex flagged: the old
    "commit-on-the-same-session" implementation could return the
    GET_LOCK connection to the pool before the enclosing
    `finally: release_named_lock` ran, leaking `mp:<hash>` for up to
    `pool_recycle` and re-opening the same-key upload race.
  - Same helper now used by `create_server_object_payload`'s put-failure
    branch — two failure paths have identical semantics.

B2: streaming copy for soft-delete + restore (`get_stream() + put()`)
  - LOCAL backend: zero-copy (aiofiles stream write). OOM fixed.
  - S3 backend: still OOMs on multi-GB objects — `put()` materializes
    the async iter via `b"".join(chunks)`. Multipart `put` is a
    follow-up; do NOT claim "OOM fixed on production" since production
    defaults to S3.

C1: worker re-verifies `Users.status='active' AND is_deleted=0`
  - `_assert_user_active` called from `_execution_context` after
    resolving `triggered_by`; skips `SYSTEM_CRON_USER_ID`.
  - `USER_DISABLED` error_code goes into the `NODE_FINISHED_EVENT`
    outbox payload — `schedule_node_runs` has no `error_code` column,
    the row only carries the `message` text. Docstrings corrected to
    say so explicitly (previous docstring falsely promised row-level
    observability).

F1: honest browser-local file lock
  - `api.ts` `acquireFileLock/heartbeatFileLock/releaseFileLock/
    releaseFileLockOnUnload` are now no-ops with comments stating they
    never call the network.
  - `scriptWorkspaceStore` dropped `tickHeartbeats`; `tickCleanup`
    simplified to just clear cache.
  - `useEditSessionLifecycle` dropped its 15s heartbeat `setInterval`.
  - `ScriptWorkspace.tsx` renders `.local-lock-banner` info bar when
    `isEditing`. Two tabs may still silently last-write — banner is the
    only guard (acceptable disclosure-only tradeoff).

Dead code: deleted the duplicate `upload_bytes_to_session` in
`backend/src/backend/storage_api.py`. The `services.storage` import
is now the only source of the function; `create_upload_record`'s
docstring updated to point at `backend.resources`.
This commit is contained in:
tao.chen
2026-08-20 12:07:52 +08:00
parent 4acfbb162f
commit b600c6810b
11 changed files with 437 additions and 206 deletions
@@ -478,6 +478,18 @@ export function ScriptWorkspace({
)}
</div>
{/* 本地编辑锁提示——只在非 Python 编辑时显示,
因为 Python 编辑走 pythonEditorBuffers,不走文件锁。
这个锁只在本浏览器当前 tab 内有效,不能阻止隐身模式 / 其它浏览器同时编辑。 */}
{isEditing && (
<div className="local-lock-banner">
<Icon name="info" size={14} />
<span>
</span>
</div>
)}
{/* 编辑器画布区域 - 始终渲染,保证 iframe 不重新加载 */}
<div className="editor-canvas-wrapper" style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
{/* 只读模式内容 - 用 CSS 控制显示/隐藏 */}
@@ -9,8 +9,9 @@ type ActivePage = "home" | "scripts" | "schedules" | "system";
/**
* 必须在 layout 层挂载,不能放在 ScriptsPage。
* 原因:心跳 / cleanup / beforeunload 需要在用户切到 /schedules 时仍运行,
* 否则其他 tab 中的编辑会话锁会过期
* 原因:cleanup / beforeunload 需要在用户切到 /schedules 时仍运行,否则跨页
* 浏览 10 分钟以上再回来时,sessionCache 里塞的全是陈旧的本地锁
* 心跳已删除——本地锁没有过期概念,存不存在 15s 定时器都一样。
*/
export function useEditSessionLifecycle({
activePage,
@@ -26,17 +27,9 @@ export function useEditSessionLifecycle({
void useScriptWorkspaceStore.getState().endEditing(true, false);
}, [activePage]);
// 2) 心跳 + cleanup 定时器(15s 心跳,60s 检查 cleanup
// 2) 本地缓存回收(60s 检查一次,10 分钟无活动的本地锁清掉
useEffect(() => {
let heartbeatRunning = false;
let cleanupRunning = false;
const heartbeatTimer = window.setInterval(() => {
if (heartbeatRunning) return;
heartbeatRunning = true;
void useScriptWorkspaceStore.getState().tickHeartbeats().finally(() => {
heartbeatRunning = false;
});
}, 15 * 1000);
const cleanupTimer = window.setInterval(() => {
if (cleanupRunning) return;
cleanupRunning = true;
@@ -47,12 +40,11 @@ export function useEditSessionLifecycle({
}
}, 60 * 1000);
return () => {
window.clearInterval(heartbeatTimer);
window.clearInterval(cleanupTimer);
};
}, []);
// 3) 卸载前释放编辑锁
// 3) 卸载前清理本地锁引用(详见 store.releaseActiveOnUnload
useEffect(() => {
const handleUnload = () => {
useScriptWorkspaceStore.getState().releaseActiveOnUnload();
@@ -128,7 +128,8 @@ type State = {
refreshReadOnlyContent: () => void; // 刷新只读内容
// 生命周期 tick(由 layout 层 useEditSessionLifecycle 周期性调用)
tickHeartbeats: () => Promise<void>;
// 本地锁无服务端心跳——tickHeartbeats 已删除,保留 tickCleanup 仅用于回收
// 闲置 10 分钟以上的本地缓存。
tickCleanup: () => void;
releaseActiveOnUnload: () => void;
};
@@ -1077,57 +1078,9 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
}
},
tickHeartbeats: async () => {
if (!_api) return;
// 1) 当前 active session 心跳
const active = _editSession;
if (active) {
try {
const updated = await _api.heartbeatFileLock(active);
if (
_editSession
&& _editSession.edit_session_id === updated.edit_session_id
) {
const merged = {
..._editSession,
session_status: updated.session_status,
expires_at: updated.expires_at,
};
_editSession = merged;
editSessionHandle.current = merged;
set({ editSession: merged });
}
} catch (error) {
_editSession = null;
editSessionHandle.current = null;
set({ editSession: null, embeddedJupyterUrl: null });
pushToast(
"error",
`编辑锁心跳已中断:${
error instanceof Error ? error.message : "请重新打开文件"
}`,
);
}
}
// 2) 缓存会话心跳(不更新 React state,只更新缓存对象本身)
const promises: Promise<void>[] = [];
for (const cached of sessionCache.values()) {
promises.push(
_api.heartbeatFileLock(cached.session)
.then((updated) => {
cached.session.session_status = updated.session_status;
cached.session.expires_at = updated.expires_at;
})
.catch(() => {
// 静默失败:等用户切回来时再处理
}),
);
}
await Promise.allSettled(promises);
},
tickCleanup: () => {
if (!_api) return;
// 本地锁:清缓存即可,不要发 releaseFileLock 请求。
// 接口是异步的,但在本地实现里等价于无操作,promise 没人 await。
const TEN_MINUTES = 10 * 60 * 1000;
const now = Date.now();
const toCleanup: string[] = [];
@@ -1141,15 +1094,11 @@ export const useScriptWorkspaceStore = create<State>((set, get) => {
}
if (toCleanup.length === 0) return;
for (const scriptId of toCleanup) {
const cached = sessionCache.get(scriptId);
if (cached) {
_api.releaseFileLock(cached.session).catch(console.warn);
sessionCache.delete(scriptId);
}
sessionCache.delete(scriptId);
}
pushToast(
"info",
`已清理 ${toCleanup.length} 个长时间未活动的编辑会话`,
`已清理 ${toCleanup.length} 个长时间未活动的本地编辑会话`,
);
},