fix: file upload error

This commit is contained in:
tao.chen
2026-08-14 18:22:46 +08:00
parent c65d6dc684
commit 5d49ff5e34
14 changed files with 872 additions and 604 deletions
+13
View File
@@ -245,6 +245,19 @@ async def bind_resource(
item.visibility = payload.visibility
# (description lives on DataResources, not on StorageObjects.)
existing_active = await session.scalar(
select(DataResources).where(
DataResources.workspace_id == context.workspace.workspace_id,
DataResources.resource_name == payload.resource_name,
DataResources.status == "active",
)
)
if existing_active is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"a data resource with this name already exists in this workspace",
)
existing = await session.scalar(
select(DataResources).where(
DataResources.storage_object_id == item.storage_object_id
+26 -69
View File
@@ -48,6 +48,8 @@ from backend.schemas import (
from backend.services.storage import (
create_download_url_payload,
create_server_object_payload,
soft_delete_object,
_resolve_unique_object_key,
)
router = APIRouter(tags=["scripts"])
@@ -464,6 +466,14 @@ async def create_script_record(
# storage_uri points at where the replicated bytes will land.
object_id = new_ulid()
object_key = f"{workspace_id}/{jupyter_path}"
# Route through conflict helper to handle cross-entity collisions and
# to keep the "at most one is_deleted=0 per (backend, bucket, key)"
# invariant. For same-entity re-upload after soft-delete, the helper
# returns the original key because deleted rows are excluded from its
# "available" filter; for cross-entity collision it appends a ULID
# suffix so the new row gets a distinct object_key.
object_key = await _resolve_unique_object_key(session, object_key)
object_key_hash = hashlib.sha256(object_key.encode("utf-8")).digest()
bucket_name = settings.s3_workspace_bucket
relative_path = user_relative_path(
context, f"{parent}/{jupyter_basename}" if parent else jupyter_basename
@@ -478,7 +488,7 @@ async def create_script_record(
storage_backend=settings.storage_backend,
bucket_name=bucket_name,
object_key=object_key,
object_key_hash=hashlib.sha256(object_key.encode("utf-8")).digest(),
object_key_hash=object_key_hash,
storage_uri=build_storage_uri(bucket_name, object_key),
file_name=name,
file_extension=PurePosixPath(jupyter_basename).suffix.lower() or None,
@@ -813,13 +823,16 @@ async def create_workspace_directory(
status.HTTP_404_NOT_FOUND,
"parent directory not found",
)
# Conflict check uses the unique index on (workspace_id, storage_backend, path_hash).
# A soft-deleted row at the same path can be revived; an available row is a conflict.
# Conflict check relies on SELECT ... FOR UPDATE over (workspace_id, storage_backend,
# path_hash). The underlying index is no longer unique, so conflict determination is
# fully application-level: a soft-deleted row at the same path can be revived, while
# an available row is a conflict. Concurrent inserts are no longer serialized by a
# DB unique constraint; callers must ensure the FOR UPDATE lock covers the race.
existing = await session.scalar(
select(StorageObjects)
.where(
StorageObjects.workspace_id == context.workspace.workspace_id,
StorageObjects.storage_backend == "rustfs",
StorageObjects.storage_backend == settings.storage_backend,
StorageObjects.path_hash == path_hash,
)
.with_for_update()
@@ -969,22 +982,13 @@ async def delete_workspace_directory(
.all()
)
runtime_client = request.app.state.runtime_client
workspace_id = context.workspace.workspace_id
deleted_scripts = 0
for descendant in descendants:
if descendant.object_type == "file":
jupyter_path = descendant.object_key.removeprefix(f"{workspace_id}/")
try:
await runtime_client.delete_file(workspace_id, name=jupyter_path)
except RuntimeClientError as exc:
if exc.status_code != 404:
logger.warning(
f"delete_workspace_directory: jupyter delete failed "
f"for file {descendant.storage_object_id}: "
f"{exc.status_code} {exc.detail}"
)
await soft_delete_object(
descendant.storage_object_id, request, session
)
script = await session.scalar(
select(Scripts).where(
Scripts.workspace_id == context.workspace.workspace_id,
@@ -1000,47 +1004,10 @@ async def delete_workspace_directory(
descendant.is_deleted = 1
descendant.deleted_at = datetime.now(UTC).replace(tzinfo=None)
else:
# Sub-directory jupyter path: derive from relative_path (new rows carry
# the user-supplied name; legacy ULID-pathed rows fall back to the
# storage_object_id for backward compatibility).
descendant_relative = descendant.relative_path or ""
descendant_user_prefix = f"workspace/{descendant.owner_user_id}"
if descendant_relative.startswith(descendant_user_prefix + "/"):
desc_segment = descendant_relative[len(descendant_user_prefix) + 1:]
else:
desc_segment = descendant.storage_object_id
try:
await runtime_client.delete_directory(
workspace_id, name=f"{descendant.owner_user_id}/{desc_segment}"
)
except RuntimeClientError as exc:
if exc.status_code != 404:
logger.warning(
f"delete_workspace_directory: jupyter delete failed "
f"for directory {descendant.storage_object_id}: "
f"{exc.status_code} {exc.detail}"
)
descendant.object_status = "deleted"
descendant.is_deleted = 1
descendant.deleted_at = datetime.now(UTC).replace(tzinfo=None)
# Target directory jupyter path: derive from the user-relative target
# path; fall back to the storage_object_id for legacy ULID-pathed rows.
target_user_prefix = f"workspace/{context.user.user_id}"
if target_relative.startswith(target_user_prefix + "/"):
target_segment = target_relative[len(target_user_prefix) + 1:]
else:
target_segment = target_ulid
try:
await runtime_client.delete_directory(workspace_id, name=f"{context.user.user_id}/{target_segment}")
except RuntimeClientError as exc:
if exc.status_code != 404:
logger.warning(
f"delete_workspace_directory: jupyter delete failed "
f"for target directory {target_ulid}: "
f"{exc.status_code} {exc.detail}"
)
target_dir_row.object_status = "deleted"
target_dir_row.is_deleted = 1
target_dir_row.deleted_at = datetime.now(UTC).replace(tzinfo=None)
@@ -1271,8 +1238,8 @@ async def delete_script(
session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
# Load the working-copy StorageObject if it exists. Jupyter-only
# scripts may not have a StorageObjects row; fall back to the flat
# _jupyter_path() name in that case.
# scripts may not have a StorageObjects row; in that case we only
# flip the Scripts row to deleted.
script, storage_object = await get_script_row(
script_id,
context,
@@ -1286,20 +1253,10 @@ async def delete_script(
is_admin=context.is_admin,
)
runtime_client = request.app.state.runtime_client
workspace_id = context.workspace.workspace_id
jupyter_path = _derive_jupyter_path(
storage_object, workspace_id, script.script_type, script.script_id
)
try:
await runtime_client.delete_file(workspace_id, name=jupyter_path)
except RuntimeClientError as exc:
raise HTTPException(
status_code=exc.status_code,
detail=exc.detail,
) from exc
if storage_object:
if storage_object is not None:
await soft_delete_object(
storage_object.storage_object_id, request, session
)
storage_object.object_status = "deleted"
storage_object.is_deleted = 1
storage_object.deleted_at = datetime.now(UTC).replace(tzinfo=None)
+7 -2
View File
@@ -117,6 +117,11 @@ async def _resolve_unique_object_key(
select(StorageObjects).where(
StorageObjects.object_key_hash == key_hash,
StorageObjects.object_status == "available",
# Defense-in-depth: `object_status` is the canonical
# active/deleted flag, but `is_deleted` mirrors it. Filter
# both so future code that flips one without the other
# cannot bypass the active-row check.
StorageObjects.is_deleted == 0,
)
)
if existing is None:
@@ -531,7 +536,7 @@ async def create_download_url_payload(
if item is None or item.object_status != "available":
raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found")
if (
item.storage_backend != "s3"
item.storage_backend != settings.storage_backend
or not item.bucket_name
or not item.object_key
):
@@ -588,7 +593,7 @@ async def soft_delete_object(
"trash_bucket": settings.s3_trash_bucket,
}
}
if item.storage_backend == "s3" and item.bucket_name and item.object_key:
if item.storage_backend == settings.storage_backend and item.bucket_name and item.object_key:
trash_key = f"{item.bucket_name}/{item.object_key}"
try:
object_stores = request.app.state.object_stores