2222from collections .abc import Collection , Sequence
2323import dataclasses
2424import datetime
25+ import uuid
2526
2627from absl import logging
2728from 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
4449class 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,66 @@ 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+ async def trigger_l0_to_l1_copy (
432+ session : AsyncSession ,
433+ db_asset : db_schema .Asset ,
434+ ) -> None :
435+ """Triggers copying the asset from Level 0 to Level 1 storage.
436+
437+ We assume always preserve policy, so trigger L0-L1 immediately.
438+ Generically calls them L0 and L1 as they are not always Lustre and GCS.
439+
440+ Args:
441+ session: The database session.
442+ db_asset: The Asset model instance to copy.
443+ """
444+ # Look up Level 1 storage backend to queue the copy job
445+ stmt = select (db_schema .StorageBackend ).where (
446+ db_schema .StorageBackend .level == 1
447+ )
448+ res = await session .execute (stmt )
449+ level_1_backends = res .scalars ().all ()
450+
451+ if len (level_1_backends ) > 1 :
452+ raise ValueError ("Multiple Level 1 backends are not supported yet." )
453+ l1_backend = level_1_backends [0 ] if level_1_backends else None
454+
455+ if l1_backend is not None :
456+ l1_tp_uuid = uuid .uuid4 ().hex
457+ l1_path = storage_backend_lib .get_storage_path (
458+ l1_backend , db_asset .path , l1_tp_uuid
459+ )
460+ new_l1_tp = db_schema .TierPath (
461+ storage_backend = l1_backend ,
462+ path = l1_path ,
463+ tier_path_uuid = l1_tp_uuid ,
464+ state = db_schema .TierPathState .PENDING ,
465+ )
466+ db_asset .tier_paths .append (new_l1_tp )
467+
468+ db_job = db_schema .AssetJob (
469+ asset_uuid = db_asset .asset_uuid ,
470+ request_type = db_schema .RequestType .REQUEST_TYPE_COPY ,
471+ status = db_schema .JobStatus .JOB_STATUS_QUEUED ,
472+ target_tier_path = new_l1_tp ,
473+ )
474+ session .add (db_job )
475+ logging .info (
476+ "Finalize: Queued copy job to Level 1 for asset %s, target path: %s" ,
477+ db_asset .asset_uuid ,
478+ l1_path ,
479+ )
480+ await session .commit ()
481+
482+
406483async def is_delete_pending (session : AsyncSession , * , asset_uuid : str ) -> bool :
407484 """Checks if there is a pending delete job for the asset."""
408485 stmt = (
@@ -453,6 +530,7 @@ async def create_prefetch_job(
453530 * ,
454531 backend : db_schema .StorageBackend ,
455532 storage_path : str ,
533+ tier_path_uuid : str ,
456534 client_keep_alive_interval : datetime .timedelta ,
457535) -> CreatePrefetchJobResult :
458536 """Queues a prefetch job for the given asset to the target backend.
@@ -468,6 +546,7 @@ async def create_prefetch_job(
468546 db_asset: The asset to prefetch.
469547 backend: The target storage backend (level 0).
470548 storage_path: The storage path to use for the new TierPath.
549+ tier_path_uuid: The pre-generated unique identifier for the TierPath.
471550 client_keep_alive_interval: The interval to set for the initial expires_at
472551 of the TierPath.
473552
@@ -479,6 +558,14 @@ async def create_prefetch_job(
479558 DeletionPendingError: If the asset is already marked for deletion.
480559 """
481560
561+ stmt = (
562+ select (db_schema .Asset )
563+ .where (db_schema .Asset .asset_uuid == db_asset .asset_uuid )
564+ .options (sqlalchemy .orm .joinedload (db_schema .Asset .tier_paths ))
565+ )
566+ res = await session .execute (stmt )
567+ db_asset = res .scalars ().first ()
568+
482569 # Check if there is already a preceding delete job
483570 if await is_delete_pending (session , asset_uuid = db_asset .asset_uuid ):
484571 raise DeletionPendingError (
@@ -494,9 +581,11 @@ async def create_prefetch_job(
494581 db_asset .asset_uuid ,
495582 backend .id ,
496583 )
584+ backend = await session .merge (backend )
497585 new_tp = db_schema .TierPath (
498586 storage_backend = backend ,
499587 path = storage_path ,
588+ tier_path_uuid = tier_path_uuid ,
500589 expires_at = calculate_expires_at (client_keep_alive_interval ),
501590 )
502591 db_asset .tier_paths .append (new_tp )
@@ -550,13 +639,17 @@ async def prefetch_keep_alive(
550639 DeletionPendingError: If the asset associated with the TierPath is marked
551640 for deletion, or if the specific TierPath instance is marked for
552641 deletion.
642+ PrefetchFailedError: If the prefetch operation on the TierPath failed.
553643 """
554644 stmt = select (db_schema .TierPath ).filter_by (tier_path_uuid = tier_path_uuid )
555645 result = await session .execute (stmt )
556646 tp = result .scalars ().first ()
557647 if tp is None :
558648 return None
559649
650+ if tp .state == db_schema .TierPathState .FAILED :
651+ raise PrefetchFailedError (f"Prefetch failed for TierPath { tier_path_uuid } ." )
652+
560653 if await is_delete_pending (session , asset_uuid = tp .asset_uuid ):
561654 raise DeletionPendingError (f"Asset { tp .asset_uuid } is marked for deletion." )
562655
@@ -645,3 +738,93 @@ async def queue_delete_asset_job(
645738 session .add (db_job ) # pyrefly: ignore[missing-attribute]
646739
647740 await session .commit ()
741+
742+
743+ async def begin_delete_tier_path (
744+ session : AsyncSession ,
745+ tier_path : db_schema .TierPath ,
746+ ) -> db_schema .TierPath :
747+ """Transitions a tier path state to DELETE_IN_PROCESS and clears read access."""
748+ tier_path .state = db_schema .TierPathState .DELETE_IN_PROCESS
749+ tier_path .ready_at = None
750+ tier_path .expires_at = None
751+ session .add (tier_path ) # pyrefly: ignore[missing-attribute]
752+ return tier_path
753+
754+
755+ async def begin_delete_asset (
756+ session : AsyncSession ,
757+ db_asset : db_schema .Asset ,
758+ ) -> db_schema .Asset :
759+ """Transitions asset state to DELETED and all its tier paths to DELETE_IN_PROCESS."""
760+ db_asset .state = db_schema .AssetState .ASSET_STATE_DELETED
761+ db_asset .deleted_at = datetime .datetime .now (datetime .timezone .utc )
762+ session .add (db_asset ) # pyrefly: ignore[missing-attribute]
763+ stmt = (
764+ select (db_schema .Asset )
765+ .where (db_schema .Asset .asset_uuid == db_asset .asset_uuid )
766+ .options (sqlalchemy .orm .joinedload (db_schema .Asset .tier_paths ))
767+ )
768+ res = await session .execute (stmt )
769+ db_asset = res .scalars ().first ()
770+ for tp in db_asset .tier_paths :
771+ tp .state = db_schema .TierPathState .DELETE_IN_PROCESS
772+ tp .ready_at = None
773+ tp .expires_at = None
774+ session .add (tp ) # pyrefly: ignore[missing-attribute]
775+ return db_asset
776+
777+
778+ async def complete_delete_asset (
779+ session : AsyncSession ,
780+ db_asset : db_schema .Asset ,
781+ ) -> db_schema .Asset :
782+ """Transitions asset state to DELETED and marks all tier paths as deleted.
783+
784+ Args:
785+ session: The database session.
786+ db_asset: The Asset model instance to delete.
787+
788+ Returns:
789+ The updated Asset object.
790+ """
791+ stmt = (
792+ select (db_schema .Asset )
793+ .where (db_schema .Asset .asset_uuid == db_asset .asset_uuid )
794+ .options (sqlalchemy .orm .joinedload (db_schema .Asset .tier_paths ))
795+ )
796+ res = await session .execute (stmt )
797+ db_asset = res .scalars ().first ()
798+
799+ now = datetime .datetime .now (datetime .timezone .utc )
800+ db_asset .state = db_schema .AssetState .ASSET_STATE_DELETED
801+ db_asset .deleted_at = now
802+ session .add (db_asset ) # pyrefly: ignore[missing-attribute]
803+
804+ for tp in db_asset .tier_paths :
805+ tp .state = db_schema .TierPathState .DELETED
806+ tp .ready_at = None
807+ tp .expires_at = None
808+ session .add (tp ) # pyrefly: ignore[missing-attribute]
809+
810+ return db_asset
811+
812+
813+ async def complete_delete_tier_path (
814+ session : AsyncSession ,
815+ tier_path : db_schema .TierPath ,
816+ ) -> db_schema .TierPath :
817+ """Transitions a tier path state to DELETED.
818+
819+ Args:
820+ session: The database session.
821+ tier_path: The TierPath model instance to update.
822+
823+ Returns:
824+ The updated TierPath object.
825+ """
826+ tier_path .state = db_schema .TierPathState .DELETED
827+ tier_path .ready_at = None
828+ tier_path .expires_at = None
829+ session .add (tier_path ) # pyrefly: ignore[missing-attribute]
830+ return tier_path
0 commit comments