fix: delete error
This commit is contained in:
+3
-3
@@ -61,9 +61,9 @@ S3_PORT=9000
|
||||
S3_ENDPOINT=http://127.0.0.1:9000
|
||||
S3_ACCESS_KEY=change-me
|
||||
S3_SECRET_KEY=change-me
|
||||
S3_WORKSPACE_BUCKET=workspaces
|
||||
S3_VERSION_BUCKET=versions
|
||||
S3_RUN_LOG_BUCKET=run-logs
|
||||
S3_WORKSPACE_BUCKET=workspace
|
||||
S3_VERSION_BUCKET=version
|
||||
S3_RUN_LOG_BUCKET=run-log
|
||||
S3_TRASH_BUCKET=trash
|
||||
S3_TRASH_RETENTION_DAYS=30
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ from common.storage.schemas import (
|
||||
DownloadUrlRequest,
|
||||
ServerObjectRequest,
|
||||
)
|
||||
from common.storage import actual_bucket_name, build_storage_uri
|
||||
from common.storage import USAGE_TYPE_TO_PURPOSE, actual_bucket_name, build_storage_uri
|
||||
|
||||
|
||||
# ── shared low-level helpers (module-private) ────────────────────────────
|
||||
@@ -594,11 +594,13 @@ async def soft_delete_object(
|
||||
}
|
||||
}
|
||||
if item.storage_backend == settings.storage_backend and item.bucket_name and item.object_key:
|
||||
trash_key = f"{item.bucket_name}/{item.object_key}"
|
||||
source_purpose = USAGE_TYPE_TO_PURPOSE[item.usage_type]
|
||||
trash_key = f"{source_purpose}/{item.object_key}"
|
||||
trash_bucket = actual_bucket_name("trash")
|
||||
try:
|
||||
object_stores = request.app.state.object_stores
|
||||
data = await object_stores[item.bucket_name].get(item.object_key)
|
||||
await object_stores[actual_bucket_name("trash")].put(trash_key, data)
|
||||
await object_stores[trash_bucket].put(trash_key, data)
|
||||
await object_stores[item.bucket_name].delete(item.object_key)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
@@ -606,6 +608,9 @@ async def soft_delete_object(
|
||||
f"failed to move object to trash: {exc}",
|
||||
) from exc
|
||||
item.trash_key = trash_key
|
||||
item.bucket_name = trash_bucket
|
||||
item.object_key = trash_key
|
||||
item.storage_uri = build_storage_uri(trash_bucket, trash_key)
|
||||
item.object_status = "deleted"
|
||||
item.deleted_at = _utcnow_naive()
|
||||
item.is_deleted = 1
|
||||
|
||||
@@ -19,7 +19,7 @@ from common.db.models import (
|
||||
Workspaces,
|
||||
)
|
||||
from common.ids import new_ulid
|
||||
from common.storage import actual_bucket_name, build_storage_uri
|
||||
from common.storage import USAGE_TYPE_TO_PURPOSE, actual_bucket_name, build_storage_uri
|
||||
from common.storage.schemas import (
|
||||
CreateUploadRequest,
|
||||
DownloadUrlRequest,
|
||||
@@ -56,20 +56,6 @@ def safe_file_name(value: str) -> str:
|
||||
return name
|
||||
|
||||
|
||||
# Map an upload's usage_type to its purpose (which then resolves to the
|
||||
# actual bucket / directory via ``actual_bucket_name``). Keeping the
|
||||
# purpose as the intermediate value means s3 mode and local mode share
|
||||
# the same routing logic — only the final ``actual_bucket_name`` differs.
|
||||
USAGE_TYPE_TO_PURPOSE: dict[str, str] = {
|
||||
"working_copy": "workspace",
|
||||
"public_script": "workspace",
|
||||
"data_resource": "workspace",
|
||||
"snapshot": "workspace",
|
||||
"version_artifact": "version",
|
||||
"run_log": "run_log",
|
||||
"run_result": "run_log",
|
||||
}
|
||||
|
||||
# Pre-resolved bucket map (for read-only callers like services/storage.py).
|
||||
# Re-resolved at module load; re-resolve via resolve_bucket() if the
|
||||
# workspace.artifact_bucket override matters.
|
||||
@@ -479,8 +465,12 @@ async def restore_object(
|
||||
try:
|
||||
# Cross-backend copy: get from trash, put back to source bucket.
|
||||
object_stores = request.app.state.object_stores
|
||||
data = await object_stores[actual_bucket_name("trash")].get(item.trash_key)
|
||||
await object_stores[item.bucket_name].put(item.object_key, data)
|
||||
data = await object_stores[actual_bucket_name("trash")].get(item.object_key)
|
||||
source_purpose, _, source_key = item.object_key.partition("/")
|
||||
if not source_purpose:
|
||||
source_purpose = "workspace"
|
||||
target_bucket = actual_bucket_name(source_purpose)
|
||||
await object_stores[target_bucket].put(source_key, data)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
@@ -488,6 +478,9 @@ async def restore_object(
|
||||
) from exc
|
||||
item.object_status = "available"
|
||||
item.deleted_at = None
|
||||
item.bucket_name = target_bucket
|
||||
item.object_key = source_key
|
||||
item.storage_uri = build_storage_uri(target_bucket, source_key)
|
||||
# Keep trash_key so the reaper can clean up the duplicate on its
|
||||
# next pass; we don't try to delete it here because a partial
|
||||
# failure would leave the user with no data.
|
||||
@@ -527,10 +520,15 @@ async def purge_trash_object(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"object is not in trash; refuse to hard-delete live data",
|
||||
)
|
||||
if item.bucket_name != actual_bucket_name("trash"):
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"object is not in trash bucket; refuse to hard-delete",
|
||||
)
|
||||
if item.trash_key:
|
||||
try:
|
||||
await request.app.state.object_stores[actual_bucket_name("trash")].delete(
|
||||
item.trash_key
|
||||
item.object_key
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -27,6 +27,7 @@ from .factory import (
|
||||
build_storage_config,
|
||||
create_storage,
|
||||
rclone_remote_spec,
|
||||
USAGE_TYPE_TO_PURPOSE,
|
||||
workspaces_root,
|
||||
)
|
||||
from .registry import register_backend, registered_backends
|
||||
@@ -36,6 +37,7 @@ __all__ = [
|
||||
"build_storage_config",
|
||||
"actual_bucket_name",
|
||||
"build_storage_uri",
|
||||
"USAGE_TYPE_TO_PURPOSE",
|
||||
"workspaces_root",
|
||||
"rclone_remote_spec",
|
||||
"RCLONE_REMOTE_NAME",
|
||||
|
||||
@@ -62,6 +62,21 @@ def create_storage(config: Dict[str, Any]) -> AnyStorageBackend:
|
||||
PURPOSE_BUCKETS: tuple[str, ...] = ("workspace", "version", "run_log", "trash")
|
||||
|
||||
|
||||
# Map an upload's usage_type to its purpose (which then resolves to the
|
||||
# actual bucket / directory via ``actual_bucket_name``). Keeping the
|
||||
# purpose as the intermediate value means s3 mode and local mode share
|
||||
# the same routing logic — only the final ``actual_bucket_name`` differs.
|
||||
USAGE_TYPE_TO_PURPOSE: dict[str, str] = {
|
||||
"working_copy": "workspace",
|
||||
"public_script": "workspace",
|
||||
"data_resource": "workspace",
|
||||
"snapshot": "workspace",
|
||||
"version_artifact": "version",
|
||||
"run_log": "run_log",
|
||||
"run_result": "run_log",
|
||||
}
|
||||
|
||||
|
||||
def actual_bucket_name(purpose: str) -> str:
|
||||
"""把 purpose 名称解析成实际桶路径 / 名(runtime 数据会存在这个字符串里)。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user