feat(13-08): implement create-folder and rename collision retry, stale guard, and reconciliation
- create_cloud_folder: bounded collision retry (up to 5 attempts) with keep_both_name counter suffix (D-05, D-06) - create_cloud_folder: stale precondition marks parent folder non-fresh and returns typed stale result (D-07) - rename_cloud_item: stale precondition marks parent folder non-fresh via folder state update (D-07) - create_cloud_folder: reconcile-before-return upserts new folder in cloud_items and invalidates parent (T-13-26) - rename_cloud_item: reconcile-before-return upserts renamed item and invalidates parent folder (T-13-26) - Import upsert_cloud_item, update_folder_state, keep_both_name, CloudResource from service modules
This commit is contained in:
+181
-24
@@ -47,9 +47,15 @@ from deps.auth import get_regular_user
|
||||
from deps.db import get_db
|
||||
from deps.utils import get_client_ip
|
||||
from services.audit import write_audit_log
|
||||
from services.cloud_items import ConnectionNotFound
|
||||
from services.cloud_items import (
|
||||
ConnectionNotFound,
|
||||
update_folder_state,
|
||||
upsert_cloud_item,
|
||||
)
|
||||
from services.cloud_operations import keep_both_name
|
||||
from services.rate_limiting import account_limiter
|
||||
from storage.cloud_base import (
|
||||
CloudResource,
|
||||
MUT_KIND_CONFLICT,
|
||||
MUT_KIND_DELETED,
|
||||
MUT_KIND_FOLDER,
|
||||
@@ -70,6 +76,9 @@ from storage.cloud_base import (
|
||||
PreviewSupport,
|
||||
)
|
||||
|
||||
# Maximum collision retry attempts for create-folder (D-06)
|
||||
_CREATE_FOLDER_MAX_RETRIES = 5
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -375,10 +384,14 @@ async def create_cloud_folder(
|
||||
"""Create a folder in connected cloud storage.
|
||||
|
||||
D-05: Name collision auto-suffix: 'Projects (1)', 'Projects (2)', etc.
|
||||
D-06: Concurrent collision race is handled by the adapter with bounded retry.
|
||||
D-06: Bounded retry up to _CREATE_FOLDER_MAX_RETRIES when a concurrent client
|
||||
takes the candidate name between check and mutation.
|
||||
D-07: Stale precondition returned by the provider stops the mutation, marks the
|
||||
parent folder as non-fresh, and returns typed {kind: 'stale'} for retry.
|
||||
|
||||
Returns {kind: 'folder', provider_item_id, name, parent_ref} on success.
|
||||
Returns typed error body for unsupported providers, auth failures, etc.
|
||||
After success, the new folder is upserted into cloud_items and the parent folder
|
||||
state is invalidated before the response is returned (reconcile-before-return).
|
||||
|
||||
T-13-01: Owner-scoped. T-13-14: No credentials in response.
|
||||
"""
|
||||
@@ -386,31 +399,90 @@ async def create_cloud_folder(
|
||||
|
||||
conn, adapter = await _resolve_and_get_adapter(session, connection_id, current_user.id)
|
||||
|
||||
result = await adapter.create_folder(
|
||||
parent_ref=body.parent_ref,
|
||||
name=body.name,
|
||||
connection_id=connection_id,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
candidate_name = body.name
|
||||
last_result: dict = {}
|
||||
|
||||
kind = result.get("kind")
|
||||
for attempt in range(_CREATE_FOLDER_MAX_RETRIES + 1):
|
||||
result = await adapter.create_folder(
|
||||
parent_ref=body.parent_ref,
|
||||
name=candidate_name,
|
||||
connection_id=connection_id,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
last_result = result
|
||||
kind = result.get("kind")
|
||||
|
||||
if kind == MUT_KIND_FOLDER:
|
||||
return {
|
||||
"kind": "folder",
|
||||
"provider_item_id": result.get("provider_item_id"),
|
||||
"name": result.get("name", body.name),
|
||||
"parent_ref": result.get("parent_ref", body.parent_ref),
|
||||
}
|
||||
if kind == MUT_KIND_FOLDER:
|
||||
# Success: reconcile before returning (T-13-24, T-13-26)
|
||||
provider_item_id = result.get("provider_item_id")
|
||||
resolved_name = result.get("name", candidate_name)
|
||||
resolved_parent_ref = result.get("parent_ref", body.parent_ref)
|
||||
|
||||
if provider_item_id:
|
||||
resource = CloudResource(
|
||||
id=uuid.uuid4(),
|
||||
provider_item_id=provider_item_id,
|
||||
connection_id=connection_id,
|
||||
user_id=current_user.id,
|
||||
name=resolved_name,
|
||||
kind="folder",
|
||||
parent_ref=resolved_parent_ref,
|
||||
)
|
||||
# Upsert gives the new folder stable row identity in cloud_items
|
||||
await upsert_cloud_item(session, user_id=str(current_user.id), resource=resource)
|
||||
|
||||
# Invalidate parent folder so next browse triggers a provider re-list
|
||||
invalidate_parent = resolved_parent_ref if resolved_parent_ref is not None else ""
|
||||
await update_folder_state(
|
||||
session,
|
||||
user_id=str(current_user.id),
|
||||
connection_id=str(connection_id),
|
||||
parent_ref=invalidate_parent,
|
||||
refresh_state="warning",
|
||||
error_code="create_folder_mutated",
|
||||
error_message="Folder contents changed by create-folder — re-listing required.",
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"kind": "folder",
|
||||
"provider_item_id": provider_item_id,
|
||||
"name": resolved_name,
|
||||
"parent_ref": resolved_parent_ref,
|
||||
}
|
||||
|
||||
if kind == MUT_KIND_CONFLICT and attempt < _CREATE_FOLDER_MAX_RETRIES:
|
||||
# D-05/D-06: auto-suffix with counter and retry
|
||||
candidate_name = keep_both_name(body.name, attempt + 1)
|
||||
continue
|
||||
|
||||
if kind == MUT_KIND_STALE:
|
||||
# D-07: Stop mutation, mark parent folder as non-fresh, return typed stale result
|
||||
stale_parent = body.parent_ref if body.parent_ref is not None else ""
|
||||
await update_folder_state(
|
||||
session,
|
||||
user_id=str(current_user.id),
|
||||
connection_id=str(connection_id),
|
||||
parent_ref=stale_parent,
|
||||
refresh_state="warning",
|
||||
error_code="stale_listing",
|
||||
error_message="Parent folder changed externally — re-listing required before retry.",
|
||||
)
|
||||
await session.commit()
|
||||
return _mutation_error_response(result)
|
||||
|
||||
# All other non-success kinds (offline, reauth, unsupported, conflict exhausted)
|
||||
break
|
||||
|
||||
# Unsupported operation — return typed body instead of 500
|
||||
if kind == MUT_KIND_UNSUPPORTED:
|
||||
if last_result.get("kind") == MUT_KIND_UNSUPPORTED:
|
||||
return {
|
||||
"kind": "unsupported_operation",
|
||||
"reason": "provider_unsupported",
|
||||
}
|
||||
|
||||
return _mutation_error_response(result)
|
||||
return _mutation_error_response(last_result)
|
||||
|
||||
|
||||
# ── Rename ────────────────────────────────────────────────────────────────────
|
||||
@@ -428,9 +500,14 @@ async def rename_cloud_item(
|
||||
) -> dict:
|
||||
"""Rename a cloud file or folder.
|
||||
|
||||
D-05: Counter-suffix collision policy — auto-suffixes on name collision.
|
||||
D-07: etag guards against operating on stale metadata; returns
|
||||
{kind: 'stale', reason: 'item_changed'} on mismatch (HTTP 409).
|
||||
D-05: Rename collision is surfaced to the user (they chose the name explicitly).
|
||||
D-07: etag guards against operating on stale metadata. On mismatch:
|
||||
stops mutation, marks parent folder as non-fresh, returns
|
||||
{kind: 'stale', reason: 'item_changed'} (HTTP 409) so the frontend
|
||||
re-lists before retrying.
|
||||
|
||||
On success: upserts the updated item name in cloud_items and invalidates the
|
||||
parent folder state before returning (reconcile-before-return, T-13-26).
|
||||
|
||||
Returns {kind: 'renamed', provider_item_id, name} on success.
|
||||
|
||||
@@ -449,12 +526,92 @@ async def rename_cloud_item(
|
||||
kind = result.get("kind")
|
||||
|
||||
if kind == MUT_KIND_UPDATED:
|
||||
# Success: reconcile the updated name in cloud_items before returning (T-13-26)
|
||||
resolved_provider_item_id = result.get("provider_item_id", item_id)
|
||||
resolved_name = result.get("name", body.new_name)
|
||||
|
||||
# Resolve the parent_ref from the existing CloudItem so we can invalidate it
|
||||
from sqlalchemy import select
|
||||
from db.models import CloudItem
|
||||
|
||||
existing_result = await session.execute(
|
||||
select(CloudItem).where(
|
||||
CloudItem.connection_id == connection_id,
|
||||
CloudItem.provider_item_id == item_id,
|
||||
CloudItem.user_id == current_user.id,
|
||||
CloudItem.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
existing_item = existing_result.scalar_one_or_none()
|
||||
item_parent_ref = existing_item.parent_ref if existing_item else None
|
||||
item_kind = existing_item.kind if existing_item else "file"
|
||||
item_content_type = existing_item.content_type if existing_item else None
|
||||
item_size = existing_item.provider_size if existing_item else None
|
||||
|
||||
# Upsert the item with the new name — uses provider_item_id as stable identity key
|
||||
resource = CloudResource(
|
||||
id=uuid.uuid4(),
|
||||
provider_item_id=resolved_provider_item_id,
|
||||
connection_id=connection_id,
|
||||
user_id=current_user.id,
|
||||
name=resolved_name,
|
||||
kind=item_kind,
|
||||
parent_ref=item_parent_ref,
|
||||
content_type=item_content_type,
|
||||
size=item_size,
|
||||
)
|
||||
await upsert_cloud_item(session, user_id=str(current_user.id), resource=resource)
|
||||
|
||||
# Invalidate the parent folder so the next browse reflects the renamed item
|
||||
invalidate_parent = item_parent_ref if item_parent_ref is not None else ""
|
||||
await update_folder_state(
|
||||
session,
|
||||
user_id=str(current_user.id),
|
||||
connection_id=str(connection_id),
|
||||
parent_ref=invalidate_parent,
|
||||
refresh_state="warning",
|
||||
error_code="rename_mutated",
|
||||
error_message="Folder contents changed by rename — re-listing required.",
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
"kind": "renamed",
|
||||
"provider_item_id": result.get("provider_item_id", item_id),
|
||||
"name": result.get("name", body.new_name),
|
||||
"provider_item_id": resolved_provider_item_id,
|
||||
"name": resolved_name,
|
||||
}
|
||||
|
||||
if kind == MUT_KIND_STALE:
|
||||
# D-07: Stop mutation, mark parent folder as non-fresh, return typed stale result.
|
||||
# Resolve parent_ref from existing CloudItem metadata if available.
|
||||
from sqlalchemy import select
|
||||
from db.models import CloudItem
|
||||
|
||||
stale_result = await session.execute(
|
||||
select(CloudItem).where(
|
||||
CloudItem.connection_id == connection_id,
|
||||
CloudItem.provider_item_id == item_id,
|
||||
CloudItem.user_id == current_user.id,
|
||||
CloudItem.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
stale_item = stale_result.scalar_one_or_none()
|
||||
stale_parent = stale_item.parent_ref if stale_item else None
|
||||
stale_parent_ref = stale_parent if stale_parent is not None else ""
|
||||
|
||||
await update_folder_state(
|
||||
session,
|
||||
user_id=str(current_user.id),
|
||||
connection_id=str(connection_id),
|
||||
parent_ref=stale_parent_ref,
|
||||
refresh_state="warning",
|
||||
error_code="stale_listing",
|
||||
error_message="Item changed externally — re-listing required before retry.",
|
||||
)
|
||||
await session.commit()
|
||||
return _mutation_error_response(result)
|
||||
|
||||
return _mutation_error_response(result)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user