fix: file upload error
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user