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:
@@ -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} 个长时间未活动的本地编辑会话`,
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -678,10 +678,12 @@ export type StableVersion = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// The current backend authorizes Jupyter through the session cookie and
|
||||
// deliberately has no persisted file-lock or access-ticket endpoints.
|
||||
// Keep the editor's session-shaped UI contract locally while opening the
|
||||
// existing authenticated Jupyter proxy directly.
|
||||
// 本地浏览器级“编辑锁”——后端没有 acquire/heartbeat/release/edit-session 表。
|
||||
// 这里的四个函数全部是占位:返回结构是为了让上层 store 的
|
||||
// _editSession / sessionCache 继续按“session”接口工作,但锁的实际作用域
|
||||
// 仅限当前 tab。关闭 tab、刷新页面、用隐身模式打开、或换浏览器,锁即失效。
|
||||
// 不要把这些函数当作鉴权或并发控制用——它们什么都不查、什么都不写。
|
||||
// 真实并发控制需要后端 edit_sessions 表 + Nginx auth_request 联动,是后续工单。
|
||||
|
||||
export async function acquireFileLock(
|
||||
workspaceId: string,
|
||||
@@ -698,9 +700,9 @@ export async function acquireFileLock(
|
||||
heartbeat_interval_seconds: 300,
|
||||
expires_at: new Date(now + 3600_000).toISOString(),
|
||||
runtime_id: workspaceId,
|
||||
jupyter_session_id: "unlocked-session",
|
||||
jupyter_session_id: "local",
|
||||
relative_path: script.relative_path,
|
||||
lock_token: "unlocked-session",
|
||||
lock_token: "local",
|
||||
script_id: script.script_id,
|
||||
script_name: script.script_name,
|
||||
jupyter_path: script.jupyter_path,
|
||||
@@ -711,10 +713,9 @@ export async function heartbeatFileLock(
|
||||
_workspaceId: string,
|
||||
session: ActiveEditSession,
|
||||
): Promise<FileLockSession> {
|
||||
return {
|
||||
...session,
|
||||
expires_at: new Date(Date.now() + 3600_000).toISOString(),
|
||||
};
|
||||
// 本地锁不存在过期概念;只是把 expires_at 推后让 UI 看着还活着。
|
||||
// 该字段当前没有任何消费者,保留只是为了不破坏契约。
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function releaseFileLock(
|
||||
@@ -728,7 +729,8 @@ export function releaseFileLockOnUnload(
|
||||
_workspaceId: string,
|
||||
_session: ActiveEditSession,
|
||||
): void {
|
||||
// No backend lock is created in compatibility mode.
|
||||
// 本地锁随 tab 生命周期结束。beforeunload 调到这里只是让 store 端
|
||||
// 清理模块级引用,避免下一个 tab 复用时看到陈旧 _editSession。
|
||||
}
|
||||
|
||||
async function waitForJupyterReady(jupyterUrl: string): Promise<void> {
|
||||
|
||||
@@ -2090,6 +2090,27 @@ button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 本地编辑锁提示:放在 editor-toolbar 下方、editor-canvas 上方。
|
||||
蓝色 info 调,与 readonly-editor-banner(黄色 hard-block)区分。
|
||||
flex-shrink: 0 让 banner 不会被 canvas 高度挤压。 */
|
||||
.local-lock-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 6px 16px;
|
||||
background: #e7f1ff;
|
||||
border-bottom: 1px solid #c9def9;
|
||||
color: #1f4e8a;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.local-lock-banner svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.readonly-editor-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
Reference in New Issue
Block a user