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_ENDPOINT=http://127.0.0.1:9000
|
||||||
S3_ACCESS_KEY=change-me
|
S3_ACCESS_KEY=change-me
|
||||||
S3_SECRET_KEY=change-me
|
S3_SECRET_KEY=change-me
|
||||||
S3_WORKSPACE_BUCKET=workspaces
|
S3_WORKSPACE_BUCKET=workspace
|
||||||
S3_VERSION_BUCKET=versions
|
S3_VERSION_BUCKET=version
|
||||||
S3_RUN_LOG_BUCKET=run-logs
|
S3_RUN_LOG_BUCKET=run-log
|
||||||
S3_TRASH_BUCKET=trash
|
S3_TRASH_BUCKET=trash
|
||||||
S3_TRASH_RETENTION_DAYS=30
|
S3_TRASH_RETENTION_DAYS=30
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ from common.storage.schemas import (
|
|||||||
DownloadUrlRequest,
|
DownloadUrlRequest,
|
||||||
ServerObjectRequest,
|
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) ────────────────────────────
|
# ── 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:
|
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:
|
try:
|
||||||
object_stores = request.app.state.object_stores
|
object_stores = request.app.state.object_stores
|
||||||
data = await object_stores[item.bucket_name].get(item.object_key)
|
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)
|
await object_stores[item.bucket_name].delete(item.object_key)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -606,6 +608,9 @@ async def soft_delete_object(
|
|||||||
f"failed to move object to trash: {exc}",
|
f"failed to move object to trash: {exc}",
|
||||||
) from exc
|
) from exc
|
||||||
item.trash_key = trash_key
|
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.object_status = "deleted"
|
||||||
item.deleted_at = _utcnow_naive()
|
item.deleted_at = _utcnow_naive()
|
||||||
item.is_deleted = 1
|
item.is_deleted = 1
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from common.db.models import (
|
|||||||
Workspaces,
|
Workspaces,
|
||||||
)
|
)
|
||||||
from common.ids import new_ulid
|
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 (
|
from common.storage.schemas import (
|
||||||
CreateUploadRequest,
|
CreateUploadRequest,
|
||||||
DownloadUrlRequest,
|
DownloadUrlRequest,
|
||||||
@@ -56,20 +56,6 @@ def safe_file_name(value: str) -> str:
|
|||||||
return name
|
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).
|
# Pre-resolved bucket map (for read-only callers like services/storage.py).
|
||||||
# Re-resolved at module load; re-resolve via resolve_bucket() if the
|
# Re-resolved at module load; re-resolve via resolve_bucket() if the
|
||||||
# workspace.artifact_bucket override matters.
|
# workspace.artifact_bucket override matters.
|
||||||
@@ -479,8 +465,12 @@ async def restore_object(
|
|||||||
try:
|
try:
|
||||||
# Cross-backend copy: get from trash, put back to source bucket.
|
# Cross-backend copy: get from trash, put back to source bucket.
|
||||||
object_stores = request.app.state.object_stores
|
object_stores = request.app.state.object_stores
|
||||||
data = await object_stores[actual_bucket_name("trash")].get(item.trash_key)
|
data = await object_stores[actual_bucket_name("trash")].get(item.object_key)
|
||||||
await object_stores[item.bucket_name].put(item.object_key, data)
|
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:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_502_BAD_GATEWAY,
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
@@ -488,6 +478,9 @@ async def restore_object(
|
|||||||
) from exc
|
) from exc
|
||||||
item.object_status = "available"
|
item.object_status = "available"
|
||||||
item.deleted_at = None
|
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
|
# 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
|
# next pass; we don't try to delete it here because a partial
|
||||||
# failure would leave the user with no data.
|
# failure would leave the user with no data.
|
||||||
@@ -527,10 +520,15 @@ async def purge_trash_object(
|
|||||||
status.HTTP_409_CONFLICT,
|
status.HTTP_409_CONFLICT,
|
||||||
"object is not in trash; refuse to hard-delete live data",
|
"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:
|
if item.trash_key:
|
||||||
try:
|
try:
|
||||||
await request.app.state.object_stores[actual_bucket_name("trash")].delete(
|
await request.app.state.object_stores[actual_bucket_name("trash")].delete(
|
||||||
item.trash_key
|
item.object_key
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from .factory import (
|
|||||||
build_storage_config,
|
build_storage_config,
|
||||||
create_storage,
|
create_storage,
|
||||||
rclone_remote_spec,
|
rclone_remote_spec,
|
||||||
|
USAGE_TYPE_TO_PURPOSE,
|
||||||
workspaces_root,
|
workspaces_root,
|
||||||
)
|
)
|
||||||
from .registry import register_backend, registered_backends
|
from .registry import register_backend, registered_backends
|
||||||
@@ -36,6 +37,7 @@ __all__ = [
|
|||||||
"build_storage_config",
|
"build_storage_config",
|
||||||
"actual_bucket_name",
|
"actual_bucket_name",
|
||||||
"build_storage_uri",
|
"build_storage_uri",
|
||||||
|
"USAGE_TYPE_TO_PURPOSE",
|
||||||
"workspaces_root",
|
"workspaces_root",
|
||||||
"rclone_remote_spec",
|
"rclone_remote_spec",
|
||||||
"RCLONE_REMOTE_NAME",
|
"RCLONE_REMOTE_NAME",
|
||||||
|
|||||||
@@ -62,6 +62,21 @@ def create_storage(config: Dict[str, Any]) -> AnyStorageBackend:
|
|||||||
PURPOSE_BUCKETS: tuple[str, ...] = ("workspace", "version", "run_log", "trash")
|
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:
|
def actual_bucket_name(purpose: str) -> str:
|
||||||
"""把 purpose 名称解析成实际桶路径 / 名(runtime 数据会存在这个字符串里)。
|
"""把 purpose 名称解析成实际桶路径 / 名(runtime 数据会存在这个字符串里)。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user