Source code for nemo_rl.environments.checkpointing.interfaces
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Save and restore rollout state that lives in the environment backend.
At turn-level recovery the environment backend holds part of every unfinished
rollout: agent sessions, tool state, the turns already run. The single
controller saves and restores that state only through ``EnvironmentCheckpoint``
and never names the backend. NeMo Gym implements it in
``nemo_rl.environments.checkpointing.nemo_gym``; a backend that keeps no
rollout state uses ``NoEnvironmentCheckpoint``.
A rollout snapshot uses the interface in this order::
async with env_checkpoint.snapshot(path) as snapshot: # backend paused
async with data_plane_barrier.checkpoint() as cut:
if snapshot is not None:
await snapshot.check_against_cut(cut)
... # RL saves TQ and its own state
# backend resumed
"""
from __future__ import annotations
import contextlib
from collections.abc import AsyncIterator
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Optional, Protocol
if TYPE_CHECKING:
from nemo_rl.algorithms.async_utils.replay_buffer import DataPlaneMutationCut
[docs]
@dataclass(frozen=True, order=True)
class EnvironmentEpisode:
"""One rollout attempt as the environment backend knows it.
The recovery ledger names the environment's rollouts this way when it asks
the backend to retire, forget or continue them.
Attributes:
rollout_id: The rollout's base ID, the same for every attempt.
attempt: How many times the backend restored this rollout. 0 is the
first run; a restore continues it as the next attempt.
"""
rollout_id: str
attempt: int
[docs]
def __post_init__(self) -> None:
if not isinstance(self.rollout_id, str) or not self.rollout_id:
raise ValueError("environment episode rollout_id must not be empty")
if (
not isinstance(self.attempt, int)
or isinstance(self.attempt, bool)
or self.attempt < 0
):
raise ValueError("environment episode attempt must be non-negative")
@property
def capture_key(self) -> str:
"""The attempt's key on rollout rows: ``<rollout_id>-a<attempt>``.
Attempt 0 uses the bare rollout ID. A backend must key its rows the
same way.
"""
return (
self.rollout_id
if self.attempt == 0
else f"{self.rollout_id}-a{self.attempt}"
)
[docs]
def next_attempt(self) -> EnvironmentEpisode:
"""Return the attempt the backend creates when it restores this one."""
return EnvironmentEpisode(self.rollout_id, self.attempt + 1)
[docs]
@dataclass(frozen=True)
class RestoredEnvironmentRollouts:
"""What an environment restore continued, so RL can match it.
Attributes:
continued_attempts: ``(instance_id, rollout_id, attempt)`` of every
rollout the backend restored. The recovery ledger continues exactly
these and starts every other unfinished rollout over.
staging_keys: Token-capture staging rows the restored rollouts still
reference. Restore keeps them instead of clearing them as orphans.
"""
continued_attempts: frozenset[tuple[str, str, int]] = frozenset()
staging_keys: frozenset[str] = frozenset()
[docs]
class EnvironmentSnapshot(Protocol):
"""The backend's part of one rollout snapshot, while the backend is paused."""
@property
def restarted_rollouts(self) -> int:
"""Rollouts the backend could not capture; they start over after a crash."""
...
[docs]
async def check_against_cut(self, cut: DataPlaneMutationCut) -> None:
"""Under the data-plane cut, require the captured state to match RL's.
Raises when the backend's rollouts and RL's ledger disagree, which
would make the snapshot unrestorable.
"""
...
[docs]
def metrics(self, paused_seconds: float) -> dict[str, float]:
"""Backend metrics for this snapshot, logged with the snapshot timings."""
...
[docs]
class EnvironmentCheckpoint(Protocol):
"""Rollout state an environment backend keeps between turns."""
@property
def holds_rollout_state(self) -> bool:
"""Whether rollout snapshots must capture this backend.
True means a trainer checkpoint, which never pauses the backend, does
not stand in for a rollout snapshot.
"""
...
[docs]
async def check_startup(self) -> None:
"""Refuse, or warn about, a deployment that cannot recover as configured."""
...
[docs]
def snapshot(
self, checkpoint_path: Path
) -> contextlib.AbstractAsyncContextManager[Optional[EnvironmentSnapshot]]:
"""Pause the backend and save its rollouts under ``checkpoint_path``.
Yields ``None`` when there is nothing to save. The backend resumes,
and accepts new rollouts again, when the context exits.
"""
...
[docs]
def restored(
self, checkpoint_path: Path
) -> contextlib.AbstractAsyncContextManager[RestoredEnvironmentRollouts]:
"""Restore the backend's rollouts from ``checkpoint_path``.
The restore is kept only if the context exits cleanly. On an exception
the backend drops what it restored before it resumes.
"""
...
[docs]
def is_unavailable(self, error: BaseException) -> bool:
"""Whether a failed snapshot only met a backend that was briefly unreachable.
Such a failure leaves nothing RL holds changed, so the next attempt
can retry.
"""
...
[docs]
class NoEnvironmentCheckpoint:
"""For a backend that keeps no rollout state between turns."""
@property
def holds_rollout_state(self) -> bool:
return False
[docs]
async def check_startup(self) -> None:
return None
[docs]
@contextlib.asynccontextmanager
async def snapshot(
self, checkpoint_path: Path
) -> AsyncIterator[Optional[EnvironmentSnapshot]]:
del checkpoint_path
yield None
[docs]
@contextlib.asynccontextmanager
async def restored(
self, checkpoint_path: Path
) -> AsyncIterator[RestoredEnvironmentRollouts]:
raise RuntimeError(
f"rollout checkpoint {checkpoint_path} contains turn-level environment "
"state, but this run's environment backend keeps none to restore"
)
yield RestoredEnvironmentRollouts() # pragma: no cover
[docs]
def is_unavailable(self, error: BaseException) -> bool:
del error
return False