This commit is contained in:
Sydney Runkle
2026-04-22 08:48:53 -04:00
parent 43ecd9dc2d
commit 1099cece53
15 changed files with 475 additions and 614 deletions
@@ -36,9 +36,6 @@ class DeltaValue:
"""Returned by DeltaChannel.checkpoint(). Represents one step's writes."""
delta: list[Any]
prev_checkpoint_id: (
str | None
) # ID of checkpoint containing previous blob; None = chain root
@dataclasses.dataclass
@@ -478,41 +475,15 @@ class BaseCheckpointSaver(Generic[V]):
"""
raise NotImplementedError
def get_channel_blob(
self,
thread_id: str,
checkpoint_ns: str,
checkpoint_id: str,
channel: str,
) -> Any:
"""Look up a single channel blob by checkpoint ID + channel name.
supports_delta_channels: bool = False
"""True if this saver assembles DeltaChannel chains inside get_tuple.
Returns NotImplemented if this saver does not support efficient
per-channel-version blob lookup. The pregel layer will fall back to
get_tuple() traversal in that case.
Savers with a dedicated blob store (InMemorySaver, PostgresSaver)
should override this for O(1) performance.
"""
return NotImplemented
async def aget_channel_blob(
self,
thread_id: str,
checkpoint_ns: str,
checkpoint_id: str,
channel: str,
) -> Any:
"""Look up a single channel blob by checkpoint ID + channel name (async).
Returns NotImplemented if this saver does not support efficient
per-channel-version blob lookup. The pregel layer will fall back to
aget_tuple() traversal in that case.
Savers with a dedicated blob store (InMemorySaver, PostgresSaver)
should override this for O(1) performance.
"""
return NotImplemented
Savers that set this to True (InMemorySaver, PostgresSaver) assemble the
full DeltaChainValue before returning from get_tuple, so the channel's
from_checkpoint method always receives a DeltaChainValue. Savers that
leave this False will return a raw DeltaValue in channel_values, which
causes DeltaChannel.from_checkpoint to raise a clear error.
"""
def get_next_version(self, current: V | None, channel: None) -> V:
"""Generate the next version ID for a channel.
@@ -20,6 +20,8 @@ from langgraph.checkpoint.base import (
Checkpoint,
CheckpointMetadata,
CheckpointTuple,
DeltaChainValue,
DeltaValue,
SerializerProtocol,
get_checkpoint_id,
get_checkpoint_metadata,
@@ -120,51 +122,75 @@ class InMemorySaver(
) -> bool | None:
return self.stack.__exit__(__exc_type, __exc_value, __traceback)
supports_delta_channels: bool = True
def _load_blobs(
self, thread_id: str, checkpoint_ns: str, versions: ChannelVersions
self,
thread_id: str,
checkpoint_ns: str,
versions: ChannelVersions,
checkpoint_id: str = "",
) -> dict[str, Any]:
channel_values: dict[str, Any] = {}
delta_channels: list[str] = []
for k, v in versions.items():
kk = (thread_id, checkpoint_ns, k, v)
if kk not in self.blobs:
continue
vv = self.blobs[kk]
if vv[0] != "empty":
if vv[0] == "delta":
delta_channels.append(k)
elif vv[0] != "empty":
channel_values[k] = self.serde.loads_typed(vv)
for channel in delta_channels:
channel_values[channel] = self._assemble_delta_chain(
thread_id, checkpoint_ns, checkpoint_id, channel
)
return channel_values
def get_channel_blob(
def _assemble_delta_chain(
self,
thread_id: str,
checkpoint_ns: str,
checkpoint_id: str,
channel: str,
) -> Any:
"""Fast-path blob lookup: checkpoint → channel version → blob."""
) -> DeltaChainValue:
"""Walk the checkpoint parent tree to collect all delta blobs for a channel."""
ns_storage = self.storage.get(thread_id, {}).get(checkpoint_ns, {})
entry = ns_storage.get(checkpoint_id)
if entry is None:
return NotImplemented
checkpoint = self.serde.loads_typed(entry[0])
version = checkpoint["channel_versions"].get(channel)
if version is None:
return NotImplemented
kk = (thread_id, checkpoint_ns, channel, version)
if kk not in self.blobs:
return NotImplemented
vv = self.blobs[kk]
if vv[0] == "empty":
return NotImplemented
return self.serde.loads_typed(vv)
async def aget_channel_blob(
self,
thread_id: str,
checkpoint_ns: str,
checkpoint_id: str,
channel: str,
) -> Any:
return self.get_channel_blob(thread_id, checkpoint_ns, checkpoint_id, channel)
blobs: list[Any] = []
current_id: str | None = checkpoint_id
seen_versions: set[str] = set()
while current_id is not None:
entry = ns_storage.get(current_id)
if entry is None:
break
checkpoint = self.serde.loads_typed(entry[0])
version = checkpoint["channel_versions"].get(channel)
if version is None:
break # channel not yet in this checkpoint
_, _, current_id = entry # advance to parent before the continue/break
if version in seen_versions:
continue # same blob already collected; keep walking to older checkpoints
seen_versions.add(version)
kk = (thread_id, checkpoint_ns, channel, version)
if kk not in self.blobs:
break
vv = self.blobs[kk]
if vv[0] == "empty":
break
blob = self.serde.loads_typed(vv)
blobs.append(blob)
if not isinstance(blob, DeltaValue):
break # hit a snapshot (plain list) — chain root found
blobs.reverse()
base = None
deltas: list[list[Any]] = []
for blob in blobs:
if isinstance(blob, DeltaValue):
deltas.append(blob.delta)
else:
base = blob
return DeltaChainValue(base=base, deltas=deltas)
def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
"""Get a checkpoint tuple from the in-memory storage.
@@ -192,7 +218,10 @@ class InMemorySaver(
checkpoint={
**checkpoint_,
"channel_values": self._load_blobs(
thread_id, checkpoint_ns, checkpoint_["channel_versions"]
thread_id,
checkpoint_ns,
checkpoint_["channel_versions"],
checkpoint_id,
),
},
metadata=self.serde.loads_typed(metadata),
@@ -228,7 +257,10 @@ class InMemorySaver(
checkpoint={
**checkpoint_,
"channel_values": self._load_blobs(
thread_id, checkpoint_ns, checkpoint_["channel_versions"]
thread_id,
checkpoint_ns,
checkpoint_["channel_versions"],
checkpoint_id,
),
},
metadata=self.serde.loads_typed(metadata),
@@ -338,6 +370,7 @@ class InMemorySaver(
thread_id,
checkpoint_ns,
checkpoint_["channel_versions"],
checkpoint_id,
),
},
metadata=metadata,
@@ -246,7 +246,7 @@ class JsonPlusSerializer(SerializerProtocol):
elif isinstance(obj, bytearray):
return "bytearray", obj
elif _is_delta_value(obj):
return "delta", _msgpack_enc({"d": obj.delta, "c": obj.prev_checkpoint_id})
return "delta", _msgpack_enc({"d": obj.delta})
else:
try:
return "msgpack", _msgpack_enc(obj)
@@ -275,7 +275,7 @@ class JsonPlusSerializer(SerializerProtocol):
raw = ormsgpack.unpackb(
data_, ext_hook=self._unpack_ext_hook, option=ormsgpack.OPT_NON_STR_KEYS
)
return DeltaValue(delta=raw["d"], prev_checkpoint_id=raw.get("c"))
return DeltaValue(delta=raw["d"])
elif self.pickle_fallback and type_ == "pickle":
return pickle.loads(data_)
else:
+1 -16
View File
@@ -990,24 +990,9 @@ def test_delta_value_serde_round_trip() -> None:
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
serde = JsonPlusSerializer()
original = DeltaValue(
delta=[{"type": "human", "content": "hi"}], prev_checkpoint_id="abc-123"
)
original = DeltaValue(delta=[{"type": "human", "content": "hi"}])
type_tag, blob = serde.dumps_typed(original)
assert type_tag == "delta"
loaded = serde.loads_typed((type_tag, blob))
assert isinstance(loaded, DeltaValue)
assert loaded.delta == original.delta
assert loaded.prev_checkpoint_id == "abc-123"
def test_delta_value_serde_chain_root() -> None:
from langgraph.checkpoint.base import DeltaValue
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
serde = JsonPlusSerializer()
original = DeltaValue(delta=[], prev_checkpoint_id=None)
type_tag, blob = serde.dumps_typed(original)
loaded = serde.loads_typed((type_tag, blob))
assert isinstance(loaded, DeltaValue)
assert loaded.prev_checkpoint_id is None
+54 -19
View File
@@ -311,33 +311,68 @@ def test_memory_saver_with_allowlist_proxy_isolated() -> None:
class TestInMemorySaverDeltaChannel:
def test_get_channel_blob(self) -> None:
"""get_channel_blob returns the deserialized blob for a checkpoint+channel."""
from langgraph.checkpoint.base import DeltaValue, empty_checkpoint
def test_load_blobs_assembles_delta_chain(self) -> None:
"""_load_blobs returns DeltaChainValue for delta channels, not raw DeltaValue."""
from langgraph.checkpoint.base import (
DeltaChainValue,
DeltaValue,
empty_checkpoint,
)
saver = InMemorySaver()
serde = JsonPlusSerializer()
thread_id, ns, channel = "t1", "", "messages"
version = "00000000000000000000000000000001.0000000000000000"
delta = DeltaValue(delta=[{"content": "hi"}], prev_checkpoint_id=None)
saver.blobs[(thread_id, ns, channel, version)] = serde.dumps_typed(delta)
v1 = "00000000000000000000000000000001.0000000000000000"
v2 = "00000000000000000000000000000002.0000000000000000"
cp = empty_checkpoint()
cp["id"] = "cp1"
cp["channel_versions"][channel] = version
delta1 = DeltaValue(delta=[{"content": "hi"}])
delta2 = DeltaValue(delta=[{"content": "bye"}])
saver.blobs[(thread_id, ns, channel, v1)] = serde.dumps_typed(delta1)
saver.blobs[(thread_id, ns, channel, v2)] = serde.dumps_typed(delta2)
cp1 = empty_checkpoint()
cp1["id"] = "cp1"
cp1["channel_versions"][channel] = v1
cp2 = empty_checkpoint()
cp2["id"] = "cp2"
cp2["channel_versions"][channel] = v2
saver.storage[thread_id][ns] = {
"cp1": (serde.dumps_typed(cp), serde.dumps_typed({}), None)
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None),
"cp2": (serde.dumps_typed(cp2), serde.dumps_typed({}), "cp1"),
}
result = saver.get_channel_blob(thread_id, ns, "cp1", channel)
assert isinstance(result, DeltaValue)
assert result.delta == [{"content": "hi"}]
assert result.prev_checkpoint_id is None
result = saver._load_blobs(thread_id, ns, {channel: v2}, "cp2")
assert channel in result
chain = result[channel]
assert isinstance(chain, DeltaChainValue)
assert chain.deltas == [[{"content": "hi"}], [{"content": "bye"}]]
def test_get_channel_blob_missing(self) -> None:
"""get_channel_blob returns NotImplemented when checkpoint or channel not found."""
saver = InMemorySaver()
assert (
saver.get_channel_blob("t1", "", "no-such-cp", "messages") is NotImplemented
def test_load_blobs_single_delta_no_parent(self) -> None:
"""Single delta with no parent checkpoint produces a chain with one delta."""
from langgraph.checkpoint.base import (
DeltaChainValue,
DeltaValue,
empty_checkpoint,
)
saver = InMemorySaver()
serde = JsonPlusSerializer()
thread_id, ns, channel = "t1", "", "messages"
v1 = "00000000000000000000000000000001.0000000000000000"
delta = DeltaValue(delta=[{"content": "only"}])
saver.blobs[(thread_id, ns, channel, v1)] = serde.dumps_typed(delta)
cp1 = empty_checkpoint()
cp1["id"] = "cp1"
cp1["channel_versions"][channel] = v1
saver.storage[thread_id][ns] = {
"cp1": (serde.dumps_typed(cp1), serde.dumps_typed({}), None)
}
result = saver._load_blobs(thread_id, ns, {channel: v1}, "cp1")
chain = result[channel]
assert isinstance(chain, DeltaChainValue)
assert chain.base is None
assert chain.deltas == [[{"content": "only"}]]