Skip to content

Commit 9112c26

Browse files
jopemachineclaude
andcommitted
feat(BA-6155): add Valkey-backed chunk-store upload session engine
Introduce the concurrency-safe upload session engine that replaces the single-file-append TUS model. Session metadata is the source of truth in Valkey, guarded by a per-session lock (SET NX + token-compare Lua release); only the chunk payload bytes live on the shared filesystem as per-offset chunk_<offset>.dat files. Coordination across multiple Storage Proxy replicas happens entirely through Valkey, so there is no dependency on filesystem lock semantics (fcntl.flock) or NFS attribute-cache coherence; chunk payloads are content-addressed by (offset, sha256) and idempotent, needing no coordination. Contents: - common/clients/valkey_client/valkey_tus: ValkeyTusClient — per-session state get/set (with TTL) + a per-session lock; reuses the Glide-based AbstractValkeyClient like the other valkey clients. - common/defs: REDIS_TUS_DB; common/metrics: VALKEY_TUS layer. - errors/upload.py: ChunkConflictError (409), UploadSessionCorruptedError (500) - services/upload/tus_session.py: - ChunkRecord / SessionState (BackendAISchema; committed_offset as the largest contiguous prefix, missing_ranges, progress_percent) / ChunkAcceptance - UploadStatus StrEnum - TusUploadSession (ensure_initialized, read_state, write_temp_chunk, commit_chunk — idempotent dup / 409 conflict / no-op when completed, assemble, cleanup) taking a TusUploadSessionArgs that carries the ValkeyTusClient. - Stale sessions auto-expire via the Valkey TTL (no separate GC sweep). - Storage RootContext now provisions a ValkeyTusClient (server.py bootstrap). - Integration tests drive a real Valkey (redis container) + tmp_path chunks. Resolves BA-6154. Resolves BA-6155. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent e035f55 commit 9112c26

19 files changed

Lines changed: 1246 additions & 1 deletion

File tree

changes/11766.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Introduce `TusUploadSession`, a concurrency-safe upload engine for resumable (TUS) uploads that keeps session metadata and a per-session lock in Valkey (via `ValkeyTusClient`) while storing chunk payloads on the shared filesystem, so multiple storage-proxy replicas can accept chunks without corruption and without relying on filesystem lock semantics.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Valkey client for TUS resumable-upload session metadata."""
2+
3+
from .client import ValkeyTusClient
4+
5+
__all__ = ["ValkeyTusClient"]
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""
2+
Valkey client for TUS resumable-upload sessions.
3+
4+
Stores per-session upload metadata (the source of truth) on the Glide-based
5+
valkey connection so that multiple Storage Proxy replicas share one view of an
6+
upload's progress without relying on shared-filesystem semantics. This client is
7+
metadata-only; the per-session distributed lock that serializes the
8+
read-modify-write window is a separate :class:`DistributedLockFactory` resource
9+
(see :mod:`ai.backend.storage.services.upload.tus_session`). The payload bytes
10+
stay on the shared filesystem; only this small metadata lives in Valkey.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import logging
16+
from typing import Final, Self
17+
18+
from glide import ExpirySet, ExpiryType
19+
20+
from ai.backend.common.clients.valkey_client.client import (
21+
AbstractValkeyClient,
22+
create_valkey_client,
23+
)
24+
from ai.backend.common.exception import BackendAIError
25+
from ai.backend.common.metrics.metric import DomainType, LayerType
26+
from ai.backend.common.resilience import (
27+
BackoffStrategy,
28+
MetricArgs,
29+
MetricPolicy,
30+
Resilience,
31+
RetryArgs,
32+
RetryPolicy,
33+
)
34+
from ai.backend.common.types import TusSessionId, ValkeyTarget
35+
from ai.backend.logging import BraceStyleAdapter
36+
37+
log = BraceStyleAdapter(logging.getLogger(__spec__.name))
38+
39+
valkey_tus_resilience = Resilience(
40+
policies=[
41+
MetricPolicy(MetricArgs(domain=DomainType.VALKEY, layer=LayerType.VALKEY_TUS)),
42+
RetryPolicy(
43+
RetryArgs(
44+
max_retries=3,
45+
retry_delay=0.1,
46+
backoff_strategy=BackoffStrategy.FIXED,
47+
non_retryable_exceptions=(BackendAIError,),
48+
)
49+
),
50+
]
51+
)
52+
53+
_STATE_KEY_PREFIX: Final = "tus.upload.session" # tus.upload.session:{session_id}
54+
55+
# Stale (never-completed / abandoned) sessions are reclaimed by this TTL, so no
56+
# separate GC sweep is needed.
57+
_DEFAULT_STATE_TTL_SECONDS: Final = 24 * 60 * 60
58+
59+
60+
class ValkeyTusClient:
61+
"""Valkey-backed metadata store for TUS uploads."""
62+
63+
_client: AbstractValkeyClient
64+
65+
def __init__(self, client: AbstractValkeyClient) -> None:
66+
self._client = client
67+
68+
@classmethod
69+
async def create(
70+
cls,
71+
valkey_target: ValkeyTarget,
72+
*,
73+
db_id: int,
74+
human_readable_name: str,
75+
) -> Self:
76+
client = create_valkey_client(
77+
valkey_target=valkey_target,
78+
db_id=db_id,
79+
human_readable_name=human_readable_name,
80+
)
81+
await client.connect()
82+
return cls(client=client)
83+
84+
@valkey_tus_resilience.apply()
85+
async def close(self) -> None:
86+
await self._client.disconnect()
87+
88+
@staticmethod
89+
def _state_key(session_id: TusSessionId) -> str:
90+
return f"{_STATE_KEY_PREFIX}:{session_id}"
91+
92+
@valkey_tus_resilience.apply()
93+
async def get_session_state(self, session_id: TusSessionId) -> bytes | None:
94+
"""Return the raw serialized session state, or ``None`` if absent."""
95+
async with self._client.client() as conn:
96+
return await conn.get(self._state_key(session_id))
97+
98+
@valkey_tus_resilience.apply()
99+
async def set_session_state(
100+
self,
101+
session_id: TusSessionId,
102+
payload: str | bytes,
103+
*,
104+
ttl_seconds: int = _DEFAULT_STATE_TTL_SECONDS,
105+
) -> None:
106+
async with self._client.client() as conn:
107+
await conn.set(
108+
self._state_key(session_id),
109+
payload,
110+
expiry=ExpirySet(ExpiryType.SEC, ttl_seconds),
111+
)

src/ai/backend/common/defs/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
REDIS_STREAM_LOCK: Final = 5
1212
REDIS_CONTAINER_LOG: Final = 6
1313
REDIS_BGTASK_DB: Final = 7
14+
REDIS_TUS_DB: Final = 8
1415

1516

1617
class RedisRole(StrEnum):

src/ai/backend/common/lock.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from collections.abc import Mapping
88
from io import IOBase
99
from pathlib import Path
10-
from typing import Any, ClassVar
10+
from typing import Any, ClassVar, Protocol, runtime_checkable
1111

1212
import trafaret as t
1313
from etcd_client import Client as EtcdClient
@@ -52,6 +52,11 @@ async def __aexit__(self, *exc_info: Any) -> bool | None:
5252
raise NotImplementedError
5353

5454

55+
@runtime_checkable
56+
class DistributedLockFactory(Protocol):
57+
def __call__(self, lock_id: str, lifetime_hint: float) -> AbstractDistributedLock: ...
58+
59+
5560
class FileLock(AbstractDistributedLock):
5661
default_timeout: float = 3 # not allow infinite timeout for safety
5762

src/ai/backend/common/metrics/metric.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,7 @@ class LayerType(enum.StrEnum):
489489
VALKEY_STREAM = "valkey_stream"
490490
VALKEY_BGTASK = "valkey_bgtask"
491491
VALKEY_VOLUME_STATS = "valkey_volume_stats"
492+
VALKEY_TUS = "valkey_tus"
492493

493494
# Client layers
494495
AGENT_CLIENT = "agent_client"

src/ai/backend/common/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ def check_typed_tuple(value: tuple[Any, ...], types: tuple[type, ...]) -> tuple[
395395
ContainerPID = NewType("ContainerPID", PID)
396396

397397
ContainerId = NewType("ContainerId", str)
398+
TusSessionId = NewType("TusSessionId", str)
398399
RuleId = NewType("RuleId", UUID)
399400
SessionId = NewType("SessionId", UUID)
400401
KernelId = NewType("KernelId", UUID)

src/ai/backend/storage/context.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
from ai.backend.common.clients.valkey_client.valkey_artifact.client import (
1616
ValkeyArtifactDownloadTrackingClient,
1717
)
18+
from ai.backend.common.clients.valkey_client.valkey_tus import ValkeyTusClient
1819
from ai.backend.common.etcd import AsyncEtcd
1920
from ai.backend.common.events.dispatcher import (
2021
EventDispatcher,
2122
EventProducer,
2223
)
2324
from ai.backend.common.health_checker.probe import HealthProbe
25+
from ai.backend.common.lock import DistributedLockFactory
2426
from ai.backend.common.metrics.metric import CommonMetricRegistry
2527
from ai.backend.logging import BraceStyleAdapter
2628

@@ -106,6 +108,8 @@ class RootContext:
106108
cors_options: Mapping[str, aiohttp_cors.ResourceOptions]
107109
manager_client_pool: ManagerHTTPClientPool
108110
valkey_artifact_client: ValkeyArtifactDownloadTrackingClient
111+
valkey_tus_client: ValkeyTusClient
112+
tus_lock_factory: DistributedLockFactory
109113
health_probe: HealthProbe
110114
volume_stats_observer: VolumeStatsObserver
111115
volume_stats_state: VolumeState

src/ai/backend/storage/errors/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@
6262
QuotaScopeNotFoundError,
6363
QuotaTreeNotFoundError,
6464
)
65+
from .upload import (
66+
ChunkConflictError,
67+
UploadSessionCorruptedError,
68+
)
6569
from .vfolder import (
6670
InvalidSubpathError,
6771
VFolderNotFoundError,
@@ -90,6 +94,9 @@
9094
"InvalidDataLengthError",
9195
"ServiceNotInitializedError",
9296
"UploadOffsetMismatchError",
97+
# upload
98+
"ChunkConflictError",
99+
"UploadSessionCorruptedError",
93100
# vfolder
94101
"VFolderNotFoundError",
95102
"InvalidSubpathError",
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""
2+
Upload session related exceptions.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from aiohttp import web
8+
9+
from ai.backend.common.exception import (
10+
BackendAIError,
11+
ErrorCode,
12+
ErrorDetail,
13+
ErrorDomain,
14+
ErrorOperation,
15+
)
16+
17+
18+
class ChunkConflictError(BackendAIError, web.HTTPConflict):
19+
"""
20+
Raised when an incoming chunk targets an offset that already holds a
21+
different chunk in the upload session (409 Conflict).
22+
"""
23+
24+
error_type = "https://api.backend.ai/probs/storage/chunk-conflict"
25+
error_title = "Upload Chunk Conflict"
26+
27+
def error_code(self) -> ErrorCode:
28+
return ErrorCode(
29+
domain=ErrorDomain.STORAGE_PROXY,
30+
operation=ErrorOperation.UPDATE,
31+
error_detail=ErrorDetail.CONFLICT,
32+
)
33+
34+
35+
class UploadSessionCorruptedError(BackendAIError, web.HTTPInternalServerError):
36+
"""
37+
Raised when the on-disk upload session metadata cannot be parsed or is
38+
structurally invalid.
39+
"""
40+
41+
error_type = "https://api.backend.ai/probs/storage/upload-session-corrupted"
42+
error_title = "Upload Session Corrupted"
43+
44+
def error_code(self) -> ErrorCode:
45+
return ErrorCode(
46+
domain=ErrorDomain.STORAGE_PROXY,
47+
operation=ErrorOperation.READ,
48+
error_detail=ErrorDetail.INTERNAL_ERROR,
49+
)

0 commit comments

Comments
 (0)