Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/10821.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Update file operation services to set vfolder `updated_at` timestamp on mutations (upload, rename, move, delete).
2 changes: 1 addition & 1 deletion src/ai/backend/manager/api/gql_legacy/vfolder.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ def parse_model(
title=metadata.get("title") or vfolder_row.name,
version=metadata.get("version") or "",
created_at=metadata.get("created") or vfolder_row.created_at,
modified_at=metadata.get("last_modified") or vfolder_row.last_used,
modified_at=metadata.get("last_modified") or vfolder_row.updated_at,
description=metadata.get("description") or "",
task=metadata.get("task") or "",
architecture=metadata.get("architecture") or "",
Expand Down
1 change: 1 addition & 0 deletions src/ai/backend/manager/data/vfolder/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ class VFolderData:
cur_size: int
created_at: datetime
last_used: datetime | None
updated_at: datetime | None
creator: str | None
unmanaged_path: str | None
ownership_type: VFolderOwnershipType
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Add `updated_at` column to `vfolders`

Revision ID: 5a139f0e951e
Revises: 9dc6609c92ce
Create Date: 2026-04-07

"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "5a139f0e951e"
down_revision = "9dc6609c92ce"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column(
"vfolders",
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
)
Comment on lines +20 to +23
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it okay not to set last_used or a default value for updated_at?



def downgrade() -> None:
op.drop_column("vfolders", "updated_at")
4 changes: 4 additions & 0 deletions src/ai/backend/manager/models/vfolder/row.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,9 @@ class VFolderRow(Base): # type: ignore[misc]
last_used: Mapped[datetime | None] = mapped_column(
"last_used", sa.DateTime(timezone=True), nullable=True
)
updated_at: Mapped[datetime | None] = mapped_column(
"updated_at", sa.DateTime(timezone=True), nullable=True
)
Comment thread
seedspirit marked this conversation as resolved.
# creator is always set to the user who created vfolder (regardless user/project types)
creator: Mapped[str | None] = mapped_column("creator", sa.String(length=128), nullable=True)
# unmanaged vfolder represents the host-side absolute path instead of storage-based path.
Expand Down Expand Up @@ -432,6 +435,7 @@ def to_data(self) -> VFolderData:
cur_size=self.cur_size or 0,
created_at=self.created_at or datetime.now(UTC),
last_used=self.last_used,
updated_at=self.updated_at,
creator=self.creator,
unmanaged_path=self.unmanaged_path,
ownership_type=self.ownership_type,
Expand Down
26 changes: 25 additions & 1 deletion src/ai/backend/manager/repositories/vfolder/repository.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import uuid
from collections.abc import Mapping, Sequence
from collections.abc import AsyncIterator, Mapping, Sequence
from contextlib import asynccontextmanager as actxmgr
from datetime import UTC, datetime
from typing import Any, cast

Expand Down Expand Up @@ -412,6 +413,27 @@ async def update_vfolder_attribute(self, updater: Updater[VFolderRow]) -> VFolde
raise VFolderNotFound()
return self._vfolder_row_to_data(result.row)

@actxmgr
async def track_content_update(self, vfolder_id: uuid.UUID) -> AsyncIterator[None]:
"""
Context manager that updates ``updated_at`` after successful content
mutations (upload, rename, move, delete, mkdir).

If the wrapped block raises, ``updated_at`` is NOT touched.
"""
yield
await self._update_vfolder_updated_at(vfolder_id)

@vfolder_repository_resilience.apply()
async def _update_vfolder_updated_at(self, vfolder_id: uuid.UUID) -> None:
async with self._db.begin_session() as session:
query = (
sa.update(VFolderRow)
.where(VFolderRow.id == vfolder_id)
.values(updated_at=sa.func.now())
)
await session.execute(query)
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update_vfolder_updated_at() ignores the case where vfolder_id doesn’t exist (the UPDATE affects 0 rows and silently succeeds). This can hide data integrity issues and differs from other repository methods that raise VFolderNotFound. Consider checking the execute result’s rowcount and raising VFolderNotFound() when it is 0.

Suggested change
await session.execute(query)
result = await session.execute(query)
if result.rowcount == 0:
raise VFolderNotFound()

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A vfolder_id that does not exist in the call path cannot be provided. The track_content_update method is called only after the file operation service has verified the existence of the vfolder


@vfolder_repository_resilience.apply()
async def move_vfolders_to_trash(self, vfolder_ids: list[uuid.UUID]) -> list[VFolderData]:
"""
Expand Down Expand Up @@ -918,6 +940,7 @@ def _vfolder_row_to_data(self, row: VFolderRow) -> VFolderData:
cur_size=row.cur_size or 0,
created_at=row.created_at or datetime.now(UTC),
last_used=row.last_used,
updated_at=row.updated_at,
creator=row.creator,
unmanaged_path=row.unmanaged_path,
ownership_type=row.ownership_type,
Expand Down Expand Up @@ -1324,6 +1347,7 @@ def _vfolder_dict_to_data(self, vfolder_dict: dict[str, Any]) -> VFolderData:
cur_size=vfolder_dict["cur_size"],
created_at=vfolder_dict["created_at"],
last_used=vfolder_dict["last_used"],
updated_at=vfolder_dict.get("updated_at"),
creator=vfolder_dict["creator"],
unmanaged_path=vfolder_dict["unmanaged_path"],
ownership_type=vfolder_dict["ownership_type"],
Expand Down
102 changes: 55 additions & 47 deletions src/ai/backend/manager/services/vfolder/services/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,13 @@ async def upload_file(
)

manager_client = self._storage_manager.get_manager_facing_client(proxy_name)
storage_reply = await manager_client.upload_file(
volume_name,
str(vfolder_id),
action.path,
action.size,
)
async with self._vfolder_repository.track_content_update(action.vfolder_uuid):
storage_reply = await manager_client.upload_file(
volume_name,
str(vfolder_id),
action.path,
action.size,
)
client_api_url = self._storage_manager.get_client_api_url(proxy_name)
return CreateUploadSessionActionResult(
vfolder_uuid=action.vfolder_uuid,
Expand Down Expand Up @@ -275,12 +276,13 @@ async def rename_file(self, action: RenameFileAction) -> RenameFileActionResult:
)

manager_client = self._storage_manager.get_manager_facing_client(proxy_name)
await manager_client.rename_file(
volume_name,
str(vfolder_id),
action.target_path,
action.new_name,
)
async with self._vfolder_repository.track_content_update(action.vfolder_uuid):
await manager_client.rename_file(
volume_name,
str(vfolder_id),
action.target_path,
action.new_name,
)
return RenameFileActionResult(vfolder_uuid=action.vfolder_uuid)

async def delete_files(self, action: DeleteFilesAction) -> DeleteFilesActionResult:
Expand All @@ -306,12 +308,13 @@ async def delete_files(self, action: DeleteFilesAction) -> DeleteFilesActionResu
)

manager_client = self._storage_manager.get_manager_facing_client(proxy_name)
await manager_client.delete_files(
volume_name,
str(vfolder_id),
action.files,
action.recursive,
)
async with self._vfolder_repository.track_content_update(action.vfolder_uuid):
await manager_client.delete_files(
volume_name,
str(vfolder_id),
action.files,
action.recursive,
)
return DeleteFilesActionResult(vfolder_uuid=action.vfolder_uuid)

async def delete_files_async(
Expand Down Expand Up @@ -348,8 +351,8 @@ async def delete_files_async(

# Call storage proxy client
manager_client = self._storage_manager.get_manager_facing_client(proxy_name)
response = await manager_client.delete_files_async(request)

async with self._vfolder_repository.track_content_update(action.vfolder_uuid):
response = await manager_client.delete_files_async(request)
return DeleteFilesAsyncActionResult(
vfolder_uuid=action.vfolder_uuid, task_id=response.bgtask_id
)
Expand Down Expand Up @@ -379,13 +382,14 @@ async def mkdir(self, action: MkdirAction) -> MkdirActionResult:
)

manager_client = self._storage_manager.get_manager_facing_client(proxy_name)
storage_reply = await manager_client.mkdir(
volume=volume_name,
vfid=str(vfolder_id),
relpath=action.path,
exist_ok=action.exist_ok,
parents=action.parents,
)
async with self._vfolder_repository.track_content_update(action.vfolder_uuid):
storage_reply = await manager_client.mkdir(
volume=volume_name,
vfid=str(vfolder_id),
relpath=action.path,
exist_ok=action.exist_ok,
parents=action.parents,
)
results = storage_reply["results"]
return MkdirActionResult(
vfolder_uuid=action.vfolder_uuid,
Expand All @@ -411,12 +415,13 @@ async def move_file(self, action: MoveFileAction) -> MoveFileActionResult:
folder_id=vfolder_data.id,
)
manager_client = self._storage_manager.get_manager_facing_client(proxy_name)
await manager_client.move_file(
volume_name,
str(vfolder_id),
action.src,
action.dst,
)
async with self._vfolder_repository.track_content_update(action.vfolder_uuid):
await manager_client.move_file(
volume_name,
str(vfolder_id),
action.src,
action.dst,
)
return MoveFileActionResult(vfolder_uuid=action.vfolder_uuid)

async def list_files_v2(self, action: ListFilesV2Action) -> ListFilesV2ActionResult:
Expand Down Expand Up @@ -477,13 +482,14 @@ async def mkdir_v2(self, action: MkdirV2Action) -> MkdirV2ActionResult:
folder_id=vfolder_data.id,
)
manager_client = self._storage_manager.get_manager_facing_client(proxy_name)
await manager_client.mkdir(
volume=volume_name,
vfid=str(vfolder_id),
relpath=action.path,
exist_ok=action.exist_ok,
parents=action.parents,
)
async with self._vfolder_repository.track_content_update(action.vfolder_id):
await manager_client.mkdir(
volume=volume_name,
vfid=str(vfolder_id),
relpath=action.path,
exist_ok=action.exist_ok,
parents=action.parents,
)
return MkdirV2ActionResult()

async def move_file_v2(self, action: MoveFileV2Action) -> MoveFileV2ActionResult:
Expand All @@ -505,12 +511,13 @@ async def move_file_v2(self, action: MoveFileV2Action) -> MoveFileV2ActionResult
folder_id=vfolder_data.id,
)
manager_client = self._storage_manager.get_manager_facing_client(proxy_name)
await manager_client.move_file(
volume_name,
str(vfolder_id),
action.src,
action.dst,
)
async with self._vfolder_repository.track_content_update(action.vfolder_id):
await manager_client.move_file(
volume_name,
str(vfolder_id),
action.src,
action.dst,
)
return MoveFileV2ActionResult()

async def delete_files_v2(self, action: DeleteFilesV2Action) -> DeleteFilesV2ActionResult:
Expand Down Expand Up @@ -538,7 +545,8 @@ async def delete_files_v2(self, action: DeleteFilesV2Action) -> DeleteFilesV2Act
recursive=action.recursive,
)
manager_client = self._storage_manager.get_manager_facing_client(proxy_name)
response = await manager_client.delete_files_async(request)
async with self._vfolder_repository.track_content_update(action.vfolder_id):
response = await manager_client.delete_files_async(request)
return DeleteFilesV2ActionResult(bgtask_id=str(response.bgtask_id))

async def download_file_v2(
Expand Down
56 changes: 56 additions & 0 deletions tests/component/vfolder/test_vfolder_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from ai.backend.manager.models.domain import domains
from ai.backend.manager.models.resource_policy import keypair_resource_policies
from ai.backend.manager.models.storage import StorageSessionManager
from ai.backend.manager.models.vfolder import vfolders

VFolderFixtureData = dict[str, Any]
VFolderFactory = Callable[..., Coroutine[Any, Any, VFolderFixtureData]]
Expand Down Expand Up @@ -545,3 +546,58 @@ async def test_non_superadmin_cannot_get_fstab(
GetFstabContentsQuery(agent_id=None),
)
assert exc_info.value.status == 403


# ===========================================================================
# updated_at Timestamp
# ===========================================================================


class TestUpdatedAtTimestamp:
"""Verify write operations set updated_at; read operations do not."""

async def test_upload_sets_updated_at(
self,
admin_registry: BackendAIClientRegistry,
target_vfolder: VFolderFixtureData,
storage_manager: StorageSessionManager,
db_engine: SAEngine,
) -> None:
"""Write operation (upload) must set updated_at on the vfolder row."""
async with db_engine.connect() as conn:
result = await conn.execute(
sa.select(vfolders.c.updated_at).where(vfolders.c.id == target_vfolder["id"])
)
assert result.scalar_one_or_none() is None

_configure_storage_mock(storage_manager)
await admin_registry.vfolder.create_upload_session(
target_vfolder["name"],
CreateUploadSessionReq(path="test-file.txt", size=1024),
)

async with db_engine.connect() as conn:
result = await conn.execute(
sa.select(vfolders.c.updated_at).where(vfolders.c.id == target_vfolder["id"])
)
assert result.scalar_one() is not None

async def test_list_files_does_not_set_updated_at(
self,
admin_registry: BackendAIClientRegistry,
target_vfolder: VFolderFixtureData,
storage_manager: StorageSessionManager,
db_engine: SAEngine,
) -> None:
"""Read operation (list_files) must NOT set updated_at."""
_configure_storage_mock(storage_manager)
await admin_registry.vfolder.list_files(
target_vfolder["name"],
ListFilesQuery(path=""),
)

async with db_engine.connect() as conn:
result = await conn.execute(
sa.select(vfolders.c.updated_at).where(vfolders.c.id == target_vfolder["id"])
)
assert result.scalar_one_or_none() is None
2 changes: 2 additions & 0 deletions tests/unit/manager/api/adapters/test_vfolder_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def vfolder_data(self) -> VFolderData:
status=VFolderOperationStatus.READY,
created_at=datetime.now(tz=UTC),
last_used=None,
updated_at=None,
domain_name="default",
)

Expand Down Expand Up @@ -159,6 +160,7 @@ def vfolder_data(self) -> VFolderData:
status=VFolderOperationStatus.READY,
created_at=datetime.now(tz=UTC),
last_used=None,
updated_at=None,
domain_name="default",
)

Expand Down
Loading
Loading