From 939a579b07c9abc53fb7a70d643ccc2f005ad6ea Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:37:56 +0800 Subject: [PATCH] feat: rclone client --- backend/src/backend/rclone_rc_client.py | 92 +++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 backend/src/backend/rclone_rc_client.py diff --git a/backend/src/backend/rclone_rc_client.py b/backend/src/backend/rclone_rc_client.py new file mode 100644 index 0000000..0be8d55 --- /dev/null +++ b/backend/src/backend/rclone_rc_client.py @@ -0,0 +1,92 @@ +"""rclone RC client. + +Used by the backend to invalidate the rclone FUSE directory cache after +writing a new workspace object (notebook / script / upload). The runtime +container already starts rclone with ``--rc --rc-addr 0.0.0.0:5572 +--rc-no-auth`` (see ``runtime/src/runtime/mount.py``), so we only need +an HTTP client here — no extra runtime plumbing. + +Failure is logged and swallowed: the VFS refresh is best-effort and +must never bubble up to the API caller. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx +from loguru import logger + + +@dataclass(frozen=True) +class RcloneRCError(Exception): + status_code: int + detail: Any + + +_RCLONE_RC_TRANSPORT_ERROR = RcloneRCError( + 503, + { + "code": "RCLONE_RC_UNAVAILABLE", + "message": "rclone RC 暂时不可用", + "retryable": True, + "details": {}, + }, +) + + +class RcloneRCClient: + def __init__(self, client: httpx.AsyncClient) -> None: + self.client = client + + async def _request( + self, + method: str, + path: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + try: + response = await self.client.request(method, path, json=payload) + except httpx.RequestError as exc: + raise _RCLONE_RC_TRANSPORT_ERROR from exc + if response.is_error: + try: + detail = response.json().get("detail", response.text) + except ValueError: + detail = response.text + raise RcloneRCError(response.status_code, detail) + return response.json() + + async def vfs_refresh( + self, + dir_path: str, + *, + recursive: bool = True, + ) -> None: + """Invalidate the FUSE dir-cache for ``dir_path``. + + Fires ``POST /vfs/refresh`` against the rclone RC server with + ``_async=true`` so the call returns immediately while rclone + performs the directory walk in the background. Errors are + logged and swallowed — a refresh failure must never fail the + API call that triggered it. + """ + try: + await self._request( + "POST", + "/vfs/refresh", + { + "dir": dir_path, + "recursive": recursive, + "_async": True, + }, + ) + logger.info( + f"vfs_refresh dir={dir_path} recursive={recursive} ok" + ) + except Exception as exc: # noqa: BLE001 - best-effort + logger.warning(f"vfs_refresh dir={dir_path} failed: {exc}") + + +__all__ = ["RcloneRCClient", "RcloneRCError"] \ No newline at end of file