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
+13 -11
View File
@@ -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> {