Skip to content

Commit 8a21f3e

Browse files
ChromeHeartsOrbax Authors
authored andcommitted
Internal Changes
PiperOrigin-RevId: 916076758
1 parent 0f9d673 commit 8a21f3e

13 files changed

Lines changed: 1697 additions & 327 deletions

File tree

Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
# Copyright 2026 The Orbax Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Asset management utilities for Tiering Service.
16+
17+
This module handles database operations for creating, fetching, updating,
18+
and finalizing assets. It also provides conversion functions between
19+
database models and protobuf messages.
20+
"""
21+
22+
from collections.abc import Collection, Sequence
23+
import datetime
24+
25+
from absl import logging
26+
from orbax.checkpoint.experimental.tiering_service import db_schema
27+
from orbax.checkpoint.experimental.tiering_service import storage_backend as storage_backend_lib
28+
from orbax.checkpoint.experimental.tiering_service.proto import tiering_service_pb2
29+
import sqlalchemy
30+
from sqlalchemy.exc import IntegrityError
31+
from sqlalchemy.ext.asyncio import AsyncSession
32+
from sqlalchemy.future import select
33+
import sqlalchemy.orm
34+
35+
from google.protobuf import timestamp_pb2
36+
37+
38+
def _proto_from_db_tier_path(
39+
tier_path: db_schema.TierPath,
40+
) -> tiering_service_pb2.TierPath:
41+
"""Converts a db_schema.TierPath to a tiering_service_pb2.TierPath.
42+
43+
Extracts storage backend details and timestamps from the database model
44+
and constructs the corresponding protobuf message.
45+
46+
Args:
47+
tier_path: The database TierPath model instance.
48+
49+
Returns:
50+
The constructed protobuf TierPath message.
51+
"""
52+
storage_backend = tier_path.storage_backend
53+
54+
def _get_location_kwargs(sb: db_schema.StorageBackend):
55+
if sb.zone is not None:
56+
return {"zone": sb.zone}
57+
if sb.region is not None:
58+
return {"region": sb.region}
59+
if sb.multi_regions is not None:
60+
return {
61+
"multi_regions": tiering_service_pb2.MultipleRegions(
62+
regions=sb.multi_regions
63+
)
64+
}
65+
return {}
66+
67+
storage_backend_kwargs = {
68+
"id": storage_backend.id,
69+
"level": storage_backend.level,
70+
"backend_type": storage_backend.backend_type.value,
71+
"prefix": storage_backend.prefix,
72+
**_get_location_kwargs(storage_backend),
73+
}
74+
75+
proto_storage_backend = tiering_service_pb2.StorageBackend(
76+
**storage_backend_kwargs
77+
)
78+
79+
ready_at_pb = None
80+
if tier_path.ready_at is not None:
81+
ready_at_pb = timestamp_pb2.Timestamp()
82+
ready_at_pb.FromDatetime(tier_path.ready_at)
83+
84+
expires_at_pb = None
85+
if tier_path.expires_at is not None:
86+
expires_at_pb = timestamp_pb2.Timestamp()
87+
expires_at_pb.FromDatetime(tier_path.expires_at)
88+
89+
return tiering_service_pb2.TierPath(
90+
id=tier_path.id,
91+
path=tier_path.path,
92+
storage_backend=proto_storage_backend,
93+
ready_at=ready_at_pb,
94+
expires_at=expires_at_pb,
95+
)
96+
97+
98+
def proto_from_db_asset(db_asset: db_schema.Asset) -> tiering_service_pb2.Asset:
99+
"""Converts a db_schema.Asset to a tiering_service_pb2.Asset.
100+
101+
Maps database fields, including relationships (tier paths) and timestamps,
102+
to the protobuf Asset representation.
103+
104+
Args:
105+
db_asset: The database Asset model instance.
106+
107+
Returns:
108+
The constructed protobuf Asset message.
109+
"""
110+
proto_asset = tiering_service_pb2.Asset(
111+
uuid=db_asset.asset_uuid,
112+
path=db_asset.path,
113+
user=db_asset.user,
114+
tags=db_asset.tags if db_asset.tags else [],
115+
state=db_asset.state.value,
116+
tier_paths=(
117+
_proto_from_db_tier_path(tier_path)
118+
for tier_path in db_asset.tier_paths
119+
),
120+
)
121+
122+
if db_asset.created_at:
123+
proto_asset.created_at.FromDatetime(db_asset.created_at)
124+
if db_asset.finalized_at:
125+
proto_asset.finalized_at.FromDatetime(db_asset.finalized_at)
126+
if db_asset.deleted_at:
127+
proto_asset.deleted_at.FromDatetime(db_asset.deleted_at)
128+
if db_asset.updated_at:
129+
proto_asset.updated_at.FromDatetime(db_asset.updated_at)
130+
131+
return proto_asset
132+
133+
134+
async def fetch_asset_by_identifier(
135+
session: AsyncSession,
136+
asset_uuid: str | None = None,
137+
path: str | None = None,
138+
inclusive_filter: Collection[db_schema.AssetState] | None = None,
139+
) -> Sequence[db_schema.Asset]:
140+
"""Fetches assets using optional asset_uuid or path identifiers with state filtering.
141+
142+
Queries the database for assets. You must provide either asset_uuid or path.
143+
If both are provided, asset_uuid takes precedence.
144+
145+
Args:
146+
session: The database session.
147+
asset_uuid: Optional UUID to filter by.
148+
path: Optional path to filter by.
149+
inclusive_filter: Optional collection of states to filter by. If provided,
150+
only assets in these states will be returned.
151+
152+
Returns:
153+
A sequence of matching Asset objects.
154+
"""
155+
if asset_uuid is None and path is None:
156+
logging.warning("No uuid or path specified")
157+
return []
158+
159+
clauses = []
160+
if asset_uuid is not None:
161+
clauses.append(db_schema.Asset.asset_uuid == asset_uuid)
162+
elif path is not None:
163+
clauses.append(db_schema.Asset.path == path)
164+
165+
if inclusive_filter is not None:
166+
clauses.append(db_schema.Asset.state.in_(inclusive_filter))
167+
168+
stmt_select = (
169+
select(db_schema.Asset)
170+
.options(
171+
sqlalchemy.orm.selectinload(db_schema.Asset.tier_paths).selectinload(
172+
db_schema.TierPath.storage_backend
173+
)
174+
)
175+
.where(*clauses)
176+
)
177+
178+
stmt = (
179+
stmt_select.order_by(db_schema.Asset.created_at.desc())
180+
if path is not None
181+
else stmt_select
182+
)
183+
184+
result = await session.execute(stmt)
185+
return result.scalars().all()
186+
187+
188+
async def fetch_asset_by_path(
189+
session: AsyncSession,
190+
path: str,
191+
inclusive_filter: Collection[db_schema.AssetState] | None = None,
192+
) -> Sequence[db_schema.Asset]:
193+
"""Fetches assets by path with optional state eligibility constraints.
194+
195+
Args:
196+
session: The database session.
197+
path: The asset path to filter by.
198+
inclusive_filter: Optional collection of states to filter by.
199+
200+
Returns:
201+
A sequence of matching Asset objects.
202+
"""
203+
return await fetch_asset_by_identifier(
204+
session, path=path, inclusive_filter=inclusive_filter
205+
)
206+
207+
208+
async def fetch_asset_by_uuid(
209+
session: AsyncSession,
210+
asset_uuid: str,
211+
inclusive_filter: Collection[db_schema.AssetState] | None = None,
212+
) -> Sequence[db_schema.Asset]:
213+
"""Fetches assets by UUID with optional state eligibility constraints.
214+
215+
Args:
216+
session: The database session.
217+
asset_uuid: The asset UUID to filter by.
218+
inclusive_filter: Optional collection of states to filter by.
219+
220+
Returns:
221+
A sequence of matching Asset objects.
222+
"""
223+
return await fetch_asset_by_identifier(
224+
session, asset_uuid=asset_uuid, inclusive_filter=inclusive_filter
225+
)
226+
227+
228+
def calculate_expires_at(
229+
interval: datetime.timedelta,
230+
grace_ratio: float = 0.2,
231+
) -> datetime.datetime:
232+
"""Calculates a new expiration timestamp with a grace period buffer.
233+
234+
The grace period acts as a buffer to account for communication delay.
235+
236+
Args:
237+
interval: The base timeout interval.
238+
grace_ratio: Optional ratio of the interval to use as a grace buffer
239+
(default is 0.2).
240+
241+
Returns:
242+
The calculated expiration datetime in UTC.
243+
"""
244+
grace_buffer = interval * grace_ratio
245+
total_interval = interval + grace_buffer
246+
return datetime.datetime.now(datetime.timezone.utc) + total_interval
247+
248+
249+
async def create_or_fetch_asset(
250+
session: AsyncSession,
251+
request: tiering_service_pb2.ReserveRequest,
252+
backend: db_schema.StorageBackend,
253+
config: tiering_service_pb2.ServerConfig,
254+
) -> db_schema.Asset:
255+
"""Creates a new asset or fetches an existing one on unique constraint conflict.
256+
257+
Attempts to insert a new Asset record with ACTIVE_WRITE state and associates
258+
it with a new TierPath on the specified backend. If an asset with the same
259+
path already exists and is active/stored, the insert will be as no-op due to
260+
database constraints, and this function will then return the existing asset.
261+
262+
Args:
263+
session: The database session.
264+
request: The ReserveRequest containing path, user, and tags.
265+
backend: The StorageBackend to associate the asset with.
266+
config: The ServerConfig to get the keep-alive interval.
267+
268+
Returns:
269+
The created or fetched Asset object.
270+
271+
Raises:
272+
ValueError: If creation fails and the existing asset cannot be retrieved.
273+
"""
274+
db_asset = db_schema.Asset(
275+
path=request.path,
276+
user=request.user,
277+
tags=list(request.tags),
278+
state=db_schema.AssetState.ASSET_STATE_ACTIVE_WRITE,
279+
write_expires_at=calculate_expires_at(
280+
datetime.timedelta(
281+
seconds=config.client_keep_alive_interval_seconds
282+
)
283+
),
284+
)
285+
storage_path = storage_backend_lib.get_storage_path(backend, request.path)
286+
tier_path = db_schema.TierPath(
287+
storage_backend=backend,
288+
path=storage_path,
289+
)
290+
db_asset.tier_paths.append(tier_path)
291+
292+
try:
293+
session.add(db_asset)
294+
await session.commit()
295+
# Refresh the asset to load DB updated fields such as updated_at.
296+
await session.refresh(
297+
db_asset, attribute_names=["created_at", "updated_at"]
298+
)
299+
return db_asset
300+
except IntegrityError:
301+
await session.rollback()
302+
303+
logging.info(
304+
"Reserve: Asset path already exists, fetching existing record: %s",
305+
request.path,
306+
)
307+
active_assets = await fetch_asset_by_path(
308+
session,
309+
request.path,
310+
inclusive_filter=[
311+
db_schema.AssetState.ASSET_STATE_ACTIVE_WRITE,
312+
db_schema.AssetState.ASSET_STATE_STORED,
313+
],
314+
)
315+
if not active_assets:
316+
# This scenario is unlikely unless the asset was deleted after the
317+
# insert attempt.
318+
raise ValueError("Failed to retrieve reserved asset.")
319+
return active_assets[0]
320+
321+
322+
async def reserve_keep_alive(
323+
session: AsyncSession,
324+
uuid_val: str,
325+
interval: datetime.timedelta,
326+
) -> db_schema.Asset | None:
327+
"""Extends the client writing keep alive expiration timestamp for an asset.
328+
329+
Args:
330+
session: The database session.
331+
uuid_val: The UUID of the asset to update.
332+
interval: The new timeout interval.
333+
334+
Returns:
335+
The updated Asset object, or None if the asset was not found.
336+
"""
337+
db_assets = await fetch_asset_by_uuid(session, uuid_val)
338+
db_asset = db_assets[0] if db_assets else None
339+
if not db_asset:
340+
return None
341+
342+
db_asset.write_expires_at = calculate_expires_at(interval)
343+
await session.commit()
344+
return db_asset
345+
346+
347+
async def finalize_asset(
348+
session: AsyncSession,
349+
db_asset: db_schema.Asset,
350+
) -> db_schema.Asset:
351+
"""Finalizes asset status, transitions state to STORED inside a transaction.
352+
353+
Updates the asset state, sets the finalized timestamp, and marks the
354+
associated tier path as ready.
355+
356+
Args:
357+
session: The database session.
358+
db_asset: The Asset model instance to finalize.
359+
360+
Returns:
361+
The finalized Asset object.
362+
363+
Raises:
364+
ValueError: If the asset is not in ACTIVE_WRITE state.
365+
"""
366+
if db_asset.state != db_schema.AssetState.ASSET_STATE_ACTIVE_WRITE:
367+
raise ValueError(
368+
f"Asset {db_asset.asset_uuid} is in state {db_asset.state.name}, but"
369+
" must be in ASSET_STATE_ACTIVE_WRITE to be finalized."
370+
)
371+
372+
now = datetime.datetime.now(datetime.timezone.utc)
373+
db_asset.state = db_schema.AssetState.ASSET_STATE_STORED
374+
db_asset.finalized_at = now
375+
db_asset.write_expires_at = None
376+
377+
for tier_path in db_asset.tier_paths:
378+
tier_path.ready_at = now
379+
# TODO: b/503445463 - Set expires_at when policy is supported.
380+
381+
await session.commit()
382+
await session.refresh(db_asset, attribute_names=["updated_at"])
383+
return db_asset

0 commit comments

Comments
 (0)