Skip to content

Commit 39bfb89

Browse files
ChromeHeartsOrbax Authors
authored andcommitted
No public description
PiperOrigin-RevId: 945821632
1 parent fbe6113 commit 39bfb89

13 files changed

Lines changed: 2502 additions & 141 deletions

File tree

checkpoint/orbax/checkpoint/experimental/tiering_service/assets.py

Lines changed: 274 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from collections.abc import Collection, Sequence
2323
import dataclasses
2424
import datetime
25+
import uuid
2526

2627
from absl import logging
2728
from orbax.checkpoint.experimental.tiering_service import db_schema
@@ -40,6 +41,10 @@ class DeletionPendingError(ValueError):
4041
"""Raised when an operation is attempted on an asset/TierPath marked for deletion."""
4142

4243

44+
class PrefetchFailedError(ValueError):
45+
"""Raised when a prefetch operation has failed."""
46+
47+
4348
@dataclasses.dataclass
4449
class CreatePrefetchJobResult:
4550
"""Result of creating a prefetch job.
@@ -105,13 +110,18 @@ def _get_location_kwargs(sb: db_schema.StorageBackend):
105110
expires_at_pb = timestamp_pb2.Timestamp()
106111
expires_at_pb.FromDatetime(tier_path.expires_at)
107112

113+
state_val = tier_path.state or db_schema.TierPathState.UNSPECIFIED
114+
proto_state_name = f"TIER_PATH_STATE_{state_val.name}"
115+
state_pb = tiering_service_pb2.TierPathState.Value(proto_state_name)
116+
108117
return tiering_service_pb2.TierPath(
109118
id=tier_path.id,
110119
path=tier_path.path,
111120
storage_backend=proto_storage_backend,
112121
ready_at=ready_at_pb,
113122
expires_at=expires_at_pb,
114123
tier_path_uuid=tier_path.tier_path_uuid,
124+
state=state_pb,
115125
)
116126

117127

@@ -271,6 +281,9 @@ async def create_or_fetch_asset(
271281
request: tiering_service_pb2.ReserveRequest,
272282
backend: db_schema.StorageBackend,
273283
config: tiering_service_pb2.ServerConfig,
284+
*,
285+
tier_path_uuid: str,
286+
storage_path: str,
274287
) -> db_schema.Asset:
275288
"""Creates a new asset or fetches an existing one on unique constraint conflict.
276289
@@ -284,6 +297,8 @@ async def create_or_fetch_asset(
284297
request: The ReserveRequest containing path, user, and tags.
285298
backend: The StorageBackend to associate the asset with.
286299
config: The ServerConfig to get the keep-alive interval.
300+
tier_path_uuid: The pre-generated UUID for the new TierPath.
301+
storage_path: The pre-generated physical storage path.
287302
288303
Returns:
289304
The created or fetched Asset object.
@@ -302,10 +317,11 @@ async def create_or_fetch_asset(
302317
)
303318
),
304319
)
305-
storage_path = storage_backend_lib.get_storage_path(backend, request.path)
320+
backend = await session.merge(backend)
306321
tier_path = db_schema.TierPath(
307322
storage_backend=backend,
308323
path=storage_path,
324+
tier_path_uuid=tier_path_uuid,
309325
)
310326
db_asset.tier_paths.append(tier_path)
311327

@@ -383,6 +399,14 @@ async def finalize_asset(
383399
Raises:
384400
ValueError: If the asset is not in ACTIVE_WRITE state.
385401
"""
402+
stmt = (
403+
select(db_schema.Asset)
404+
.where(db_schema.Asset.asset_uuid == db_asset.asset_uuid)
405+
.options(sqlalchemy.orm.joinedload(db_schema.Asset.tier_paths))
406+
)
407+
res = await session.execute(stmt)
408+
db_asset = res.scalars().first()
409+
386410
if db_asset.state != db_schema.AssetState.ASSET_STATE_ACTIVE_WRITE:
387411
raise ValueError(
388412
f"Asset {db_asset.asset_uuid} is in state {db_asset.state.name}, but"
@@ -396,13 +420,149 @@ async def finalize_asset(
396420

397421
for tier_path in db_asset.tier_paths:
398422
tier_path.ready_at = now
423+
tier_path.state = db_schema.TierPathState.READY
399424
# TODO: b/503445463 - Set expires_at when policy is supported.
400425

401426
await session.commit()
402427
await session.refresh(db_asset, attribute_names=["updated_at"])
403428
return db_asset
404429

405430

431+
def find_matching_backend(
432+
backends: Sequence[db_schema.StorageBackend],
433+
source_backend: db_schema.StorageBackend,
434+
) -> db_schema.StorageBackend | None:
435+
"""Finds a storage backend in `backends` that matches `source_backend`.
436+
437+
The matching logic prioritizes:
438+
1. Exact zone match (if both source and target have zone).
439+
2. Region match (if they share the same region, or region extracted from
440+
zone).
441+
3. Multi-regions match (if target has multi_regions containing source's
442+
region).
443+
444+
Args:
445+
backends: A list of candidate StorageBackend objects.
446+
source_backend: The source StorageBackend object.
447+
448+
Returns:
449+
The matching StorageBackend object, or None if no match is found.
450+
"""
451+
452+
def _get_region_from_zone(zone: str) -> str:
453+
return zone.rsplit("-", 1)[0]
454+
455+
# Get source region
456+
source_region = None
457+
if source_backend.zone:
458+
source_region = _get_region_from_zone(source_backend.zone)
459+
elif source_backend.region:
460+
source_region = source_backend.region
461+
462+
# 1. Match by zone first (if both have zone)
463+
if source_backend.zone:
464+
for b in backends:
465+
if b.zone and b.zone == source_backend.zone:
466+
return b
467+
468+
# 2. Match by region
469+
if source_region:
470+
# Try exact region match first
471+
for b in backends:
472+
if b.region and b.region == source_region:
473+
return b
474+
if b.zone and _get_region_from_zone(b.zone) == source_region:
475+
return b
476+
477+
# Try multi-regions match
478+
for b in backends:
479+
if b.multi_regions and source_region in b.multi_regions:
480+
return b
481+
482+
# 3. Handle source having multi_regions (if L0 is multi-regional)
483+
if source_backend.multi_regions:
484+
# Check if target has region that is in source's multi_regions
485+
for b in backends:
486+
if b.region and b.region in source_backend.multi_regions:
487+
return b
488+
if (
489+
b.zone
490+
and _get_region_from_zone(b.zone) in source_backend.multi_regions
491+
):
492+
return b
493+
if b.multi_regions:
494+
# Check overlap
495+
overlap = set(source_backend.multi_regions) & set(b.multi_regions)
496+
if overlap:
497+
return b
498+
499+
return None
500+
501+
502+
async def trigger_l0_to_l1_copy(
503+
session: AsyncSession,
504+
db_asset: db_schema.Asset,
505+
) -> None:
506+
"""Triggers copying the asset from Level 0 to Level 1 storage.
507+
508+
We assume always preserve policy, so trigger L0-L1 immediately.
509+
Generically calls them L0 and L1 as they are not always Lustre and GCS.
510+
511+
Args:
512+
session: The database session.
513+
db_asset: The Asset model instance to copy.
514+
"""
515+
# Find the Level 0 backend from the asset's existing tier paths
516+
l0_backend = None
517+
for tp in db_asset.tier_paths:
518+
if tp.storage_backend.level == 0:
519+
l0_backend = tp.storage_backend
520+
break
521+
522+
if l0_backend is None:
523+
raise ValueError(f"Asset {db_asset.asset_uuid} has no Level 0 backend.")
524+
525+
# Look up Level 1 storage backend to queue the copy job
526+
stmt = select(db_schema.StorageBackend).where(
527+
db_schema.StorageBackend.level == 1
528+
)
529+
res = await session.execute(stmt)
530+
level_1_backends = res.scalars().all()
531+
532+
l1_backend = find_matching_backend(level_1_backends, l0_backend)
533+
if level_1_backends and l1_backend is None:
534+
raise ValueError(
535+
f"No matching Level 1 backend found for Level 0 backend: {l0_backend}"
536+
)
537+
538+
if l1_backend is not None:
539+
l1_tp_uuid = str(uuid.uuid4())
540+
l1_path = storage_backend_lib.get_storage_path(
541+
l1_backend, db_asset.path, l1_tp_uuid
542+
)
543+
new_l1_tp = db_schema.TierPath(
544+
storage_backend=l1_backend,
545+
path=l1_path,
546+
tier_path_uuid=l1_tp_uuid,
547+
state=db_schema.TierPathState.PENDING,
548+
)
549+
db_asset.tier_paths.append(new_l1_tp)
550+
551+
db_job = db_schema.AssetJob(
552+
asset_uuid=db_asset.asset_uuid,
553+
request_type=db_schema.RequestType.REQUEST_TYPE_COPY,
554+
status=db_schema.JobStatus.JOB_STATUS_QUEUED,
555+
target_tier_path=new_l1_tp,
556+
)
557+
session.add(db_job)
558+
logging.info(
559+
"Finalize: Queued copy job to Level 1 for asset %s, target path: %s",
560+
db_asset.asset_uuid,
561+
l1_path,
562+
)
563+
await session.commit()
564+
565+
406566
async def is_delete_pending(session: AsyncSession, *, asset_uuid: str) -> bool:
407567
"""Checks if there is a pending delete job for the asset."""
408568
stmt = (
@@ -453,6 +613,7 @@ async def create_prefetch_job(
453613
*,
454614
backend: db_schema.StorageBackend,
455615
storage_path: str,
616+
tier_path_uuid: str,
456617
client_keep_alive_interval: datetime.timedelta,
457618
) -> CreatePrefetchJobResult:
458619
"""Queues a prefetch job for the given asset to the target backend.
@@ -468,6 +629,7 @@ async def create_prefetch_job(
468629
db_asset: The asset to prefetch.
469630
backend: The target storage backend (level 0).
470631
storage_path: The storage path to use for the new TierPath.
632+
tier_path_uuid: The pre-generated unique identifier for the TierPath.
471633
client_keep_alive_interval: The interval to set for the initial expires_at
472634
of the TierPath.
473635
@@ -479,6 +641,17 @@ async def create_prefetch_job(
479641
DeletionPendingError: If the asset is already marked for deletion.
480642
"""
481643

644+
stmt = (
645+
select(db_schema.Asset)
646+
.where(db_schema.Asset.asset_uuid == db_asset.asset_uuid)
647+
.options(sqlalchemy.orm.joinedload(db_schema.Asset.tier_paths))
648+
)
649+
res = await session.execute(stmt)
650+
db_asset = res.scalars().first()
651+
652+
if db_asset is None:
653+
return CreatePrefetchJobResult(asset=None, created=False)
654+
482655
# Check if there is already a preceding delete job
483656
if await is_delete_pending(session, asset_uuid=db_asset.asset_uuid):
484657
raise DeletionPendingError(
@@ -494,9 +667,11 @@ async def create_prefetch_job(
494667
db_asset.asset_uuid,
495668
backend.id,
496669
)
670+
backend = await session.merge(backend)
497671
new_tp = db_schema.TierPath(
498672
storage_backend=backend,
499673
path=storage_path,
674+
tier_path_uuid=tier_path_uuid,
500675
expires_at=calculate_expires_at(client_keep_alive_interval),
501676
)
502677
db_asset.tier_paths.append(new_tp)
@@ -550,13 +725,17 @@ async def prefetch_keep_alive(
550725
DeletionPendingError: If the asset associated with the TierPath is marked
551726
for deletion, or if the specific TierPath instance is marked for
552727
deletion.
728+
PrefetchFailedError: If the prefetch operation on the TierPath failed.
553729
"""
554730
stmt = select(db_schema.TierPath).filter_by(tier_path_uuid=tier_path_uuid)
555731
result = await session.execute(stmt)
556732
tp = result.scalars().first()
557733
if tp is None:
558734
return None
559735

736+
if tp.state == db_schema.TierPathState.FAILED:
737+
raise PrefetchFailedError(f"Prefetch failed for TierPath {tier_path_uuid}.")
738+
560739
if await is_delete_pending(session, asset_uuid=tp.asset_uuid):
561740
raise DeletionPendingError(f"Asset {tp.asset_uuid} is marked for deletion.")
562741

@@ -645,3 +824,97 @@ async def queue_delete_asset_job(
645824
session.add(db_job) # pyrefly: ignore[missing-attribute]
646825

647826
await session.commit()
827+
828+
829+
async def begin_delete_tier_path(
830+
session: AsyncSession,
831+
tier_path: db_schema.TierPath,
832+
) -> db_schema.TierPath:
833+
"""Transitions a tier path state to DELETE_IN_PROCESS and clears read access."""
834+
tier_path.state = db_schema.TierPathState.DELETE_IN_PROCESS
835+
tier_path.ready_at = None
836+
tier_path.expires_at = None
837+
session.add(tier_path) # pyrefly: ignore[missing-attribute]
838+
return tier_path
839+
840+
841+
async def begin_delete_asset(
842+
session: AsyncSession,
843+
db_asset: db_schema.Asset,
844+
) -> db_schema.Asset | None:
845+
"""Transitions asset state to DELETED and all its tier paths to DELETE_IN_PROCESS."""
846+
stmt = (
847+
select(db_schema.Asset)
848+
.where(db_schema.Asset.asset_uuid == db_asset.asset_uuid)
849+
.options(sqlalchemy.orm.joinedload(db_schema.Asset.tier_paths))
850+
)
851+
res = await session.execute(stmt)
852+
db_asset = res.scalars().first()
853+
854+
if db_asset is None:
855+
return None
856+
857+
db_asset.state = db_schema.AssetState.ASSET_STATE_DELETED
858+
db_asset.deleted_at = datetime.datetime.now(datetime.timezone.utc)
859+
860+
for tp in db_asset.tier_paths:
861+
tp.state = db_schema.TierPathState.DELETE_IN_PROCESS
862+
tp.ready_at = None
863+
tp.expires_at = None
864+
return db_asset
865+
866+
867+
async def complete_delete_asset(
868+
session: AsyncSession,
869+
db_asset: db_schema.Asset,
870+
) -> db_schema.Asset | None:
871+
"""Transitions asset state to DELETED and marks all tier paths as deleted.
872+
873+
Args:
874+
session: The database session.
875+
db_asset: The Asset model instance to delete.
876+
877+
Returns:
878+
The updated Asset object, or None if it was concurrently deleted.
879+
"""
880+
stmt = (
881+
select(db_schema.Asset)
882+
.where(db_schema.Asset.asset_uuid == db_asset.asset_uuid)
883+
.options(sqlalchemy.orm.joinedload(db_schema.Asset.tier_paths))
884+
)
885+
res = await session.execute(stmt)
886+
db_asset = res.scalars().first()
887+
888+
if db_asset is None:
889+
return None
890+
891+
now = datetime.datetime.now(datetime.timezone.utc)
892+
db_asset.state = db_schema.AssetState.ASSET_STATE_DELETED
893+
db_asset.deleted_at = now
894+
895+
for tp in db_asset.tier_paths:
896+
tp.state = db_schema.TierPathState.DELETED
897+
tp.ready_at = None
898+
tp.expires_at = None
899+
900+
return db_asset
901+
902+
903+
async def complete_delete_tier_path(
904+
session: AsyncSession,
905+
tier_path: db_schema.TierPath,
906+
) -> db_schema.TierPath:
907+
"""Transitions a tier path state to DELETED.
908+
909+
Args:
910+
session: The database session.
911+
tier_path: The TierPath model instance to update.
912+
913+
Returns:
914+
The updated TierPath object.
915+
"""
916+
tier_path.state = db_schema.TierPathState.DELETED
917+
tier_path.ready_at = None
918+
tier_path.expires_at = None
919+
session.add(tier_path) # pyrefly: ignore[missing-attribute]
920+
return tier_path

0 commit comments

Comments
 (0)