mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-20 22:52:29 +02:00
java langgraph-checkpoint
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
dependencies {
|
||||
// MessagePack for serialization
|
||||
implementation 'org.msgpack:msgpack-core:0.9.3'
|
||||
implementation 'org.msgpack:jackson-dataformat-msgpack:0.9.3'
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.langgraph.checkpoint.base;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Asynchronous interface for saving and loading checkpoints.
|
||||
*/
|
||||
public interface AsyncBaseCheckpointSaver {
|
||||
/**
|
||||
* Create a new checkpoint asynchronously.
|
||||
*
|
||||
* @param threadId The ID of the thread to checkpoint
|
||||
* @param channelValues The values of the channels to checkpoint
|
||||
* @return CompletableFuture with the ID of the new checkpoint
|
||||
*/
|
||||
CompletableFuture<String> checkpointAsync(String threadId, Map<String, Object> channelValues);
|
||||
|
||||
/**
|
||||
* Get values from a checkpoint asynchronously.
|
||||
*
|
||||
* @param checkpointId The ID of the checkpoint to load
|
||||
* @return CompletableFuture with the channel values from the checkpoint, or empty if not found
|
||||
*/
|
||||
CompletableFuture<Optional<Map<String, Object>>> getValuesAsync(String checkpointId);
|
||||
|
||||
/**
|
||||
* List all checkpoints for a thread asynchronously.
|
||||
*
|
||||
* @param threadId The ID of the thread
|
||||
* @return CompletableFuture with list of checkpoint IDs
|
||||
*/
|
||||
CompletableFuture<List<String>> listAsync(String threadId);
|
||||
|
||||
/**
|
||||
* Get the latest checkpoint for a thread asynchronously.
|
||||
*
|
||||
* @param threadId The ID of the thread
|
||||
* @return CompletableFuture with the ID of the latest checkpoint, or empty if none exists
|
||||
*/
|
||||
CompletableFuture<Optional<String>> latestAsync(String threadId);
|
||||
|
||||
/**
|
||||
* Delete a checkpoint asynchronously.
|
||||
*
|
||||
* @param checkpointId The ID of the checkpoint to delete
|
||||
* @return CompletableFuture completed when deletion is done
|
||||
*/
|
||||
CompletableFuture<Void> deleteAsync(String checkpointId);
|
||||
|
||||
/**
|
||||
* Clear all checkpoints for a thread asynchronously.
|
||||
*
|
||||
* @param threadId The ID of the thread
|
||||
* @return CompletableFuture completed when clearing is done
|
||||
*/
|
||||
CompletableFuture<Void> clearAsync(String threadId);
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.langgraph.checkpoint.base;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Interface for saving and loading checkpoints.
|
||||
*/
|
||||
public interface BaseCheckpointSaver {
|
||||
/**
|
||||
* Create a new checkpoint.
|
||||
*
|
||||
* @param threadId The ID of the thread to checkpoint
|
||||
* @param channelValues The values of the channels to checkpoint
|
||||
* @return The ID of the new checkpoint
|
||||
*/
|
||||
String checkpoint(String threadId, Map<String, Object> channelValues);
|
||||
|
||||
/**
|
||||
* Get values from a checkpoint.
|
||||
*
|
||||
* @param checkpointId The ID of the checkpoint to load
|
||||
* @return The channel values from the checkpoint, or empty if not found
|
||||
*/
|
||||
Optional<Map<String, Object>> getValues(String checkpointId);
|
||||
|
||||
/**
|
||||
* List all checkpoints for a thread.
|
||||
*
|
||||
* @param threadId The ID of the thread
|
||||
* @return List of checkpoint IDs
|
||||
*/
|
||||
List<String> list(String threadId);
|
||||
|
||||
/**
|
||||
* Get the latest checkpoint for a thread.
|
||||
*
|
||||
* @param threadId The ID of the thread
|
||||
* @return The ID of the latest checkpoint, or empty if none exists
|
||||
*/
|
||||
Optional<String> latest(String threadId);
|
||||
|
||||
/**
|
||||
* Delete a checkpoint.
|
||||
*
|
||||
* @param checkpointId The ID of the checkpoint to delete
|
||||
*/
|
||||
void delete(String checkpointId);
|
||||
|
||||
/**
|
||||
* Clear all checkpoints for a thread.
|
||||
*
|
||||
* @param threadId The ID of the thread
|
||||
*/
|
||||
void clear(String threadId);
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.langgraph.checkpoint.base;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Utility class for generating deterministic IDs.
|
||||
*/
|
||||
public final class ID {
|
||||
private ID() {
|
||||
// Prevent instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic UUID based on a namespace and name.
|
||||
*
|
||||
* @param namespace The namespace for the ID
|
||||
* @param name The name within the namespace
|
||||
* @return A UUID
|
||||
*/
|
||||
public static UUID uuid(String namespace, String name) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||
md.update(namespace.getBytes(StandardCharsets.UTF_8));
|
||||
md.update(name.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] digest = md.digest();
|
||||
|
||||
// Set the version (4) and variant (RFC4122) bits
|
||||
digest[6] = (byte) ((digest[6] & 0x0F) | 0x40);
|
||||
digest[8] = (byte) ((digest[8] & 0x3F) | 0x80);
|
||||
|
||||
long msb = 0;
|
||||
long lsb = 0;
|
||||
|
||||
for (int i = 0; i < 8; i++) {
|
||||
msb = (msb << 8) | (digest[i] & 0xff);
|
||||
}
|
||||
|
||||
for (int i = 8; i < 16; i++) {
|
||||
lsb = (lsb << 8) | (digest[i] & 0xff);
|
||||
}
|
||||
|
||||
return new UUID(msb, lsb);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException("SHA-1 algorithm not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a checkpoint ID.
|
||||
*
|
||||
* @param threadId The thread ID
|
||||
* @return A checkpoint ID
|
||||
*/
|
||||
public static String checkpointId(String threadId) {
|
||||
return uuid("checkpoint", threadId + "/" + System.currentTimeMillis()).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL-safe base64 encoded ID.
|
||||
*
|
||||
* @param namespace The namespace for the ID
|
||||
* @param name The name within the namespace
|
||||
* @return A URL-safe base64-encoded ID
|
||||
*/
|
||||
public static String urlSafeId(String namespace, String name) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
md.update(namespace.getBytes(StandardCharsets.UTF_8));
|
||||
md.update(name.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] digest = md.digest();
|
||||
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException("SHA-256 algorithm not available", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.langgraph.checkpoint.base.memory;
|
||||
|
||||
import com.langgraph.checkpoint.base.AsyncBaseCheckpointSaver;
|
||||
import com.langgraph.checkpoint.base.BaseCheckpointSaver;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Asynchronous in-memory implementation of a checkpoint saver.
|
||||
* This is a thin wrapper around the synchronous implementation that
|
||||
* executes operations asynchronously.
|
||||
*/
|
||||
public class AsyncMemoryCheckpointSaver implements AsyncBaseCheckpointSaver {
|
||||
private final BaseCheckpointSaver synchronousSaver;
|
||||
|
||||
/**
|
||||
* Create an async memory checkpoint saver.
|
||||
*/
|
||||
public AsyncMemoryCheckpointSaver() {
|
||||
this.synchronousSaver = new MemoryCheckpointSaver();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an async memory checkpoint saver with an existing synchronous saver.
|
||||
*
|
||||
* @param synchronousSaver The synchronous checkpoint saver to wrap
|
||||
*/
|
||||
public AsyncMemoryCheckpointSaver(BaseCheckpointSaver synchronousSaver) {
|
||||
this.synchronousSaver = synchronousSaver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<String> checkpointAsync(String threadId, Map<String, Object> channelValues) {
|
||||
return CompletableFuture.supplyAsync(() ->
|
||||
synchronousSaver.checkpoint(threadId, channelValues));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Optional<Map<String, Object>>> getValuesAsync(String checkpointId) {
|
||||
return CompletableFuture.supplyAsync(() ->
|
||||
synchronousSaver.getValues(checkpointId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<List<String>> listAsync(String threadId) {
|
||||
return CompletableFuture.supplyAsync(() ->
|
||||
synchronousSaver.list(threadId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Optional<String>> latestAsync(String threadId) {
|
||||
return CompletableFuture.supplyAsync(() ->
|
||||
synchronousSaver.latest(threadId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> deleteAsync(String checkpointId) {
|
||||
return CompletableFuture.runAsync(() ->
|
||||
synchronousSaver.delete(checkpointId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> clearAsync(String threadId) {
|
||||
return CompletableFuture.runAsync(() ->
|
||||
synchronousSaver.clear(threadId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying synchronous checkpoint saver.
|
||||
*
|
||||
* @return The synchronous checkpoint saver
|
||||
*/
|
||||
public BaseCheckpointSaver getSynchronousSaver() {
|
||||
return synchronousSaver;
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.langgraph.checkpoint.base.memory;
|
||||
|
||||
import com.langgraph.checkpoint.base.BaseCheckpointSaver;
|
||||
import com.langgraph.checkpoint.base.ID;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* In-memory implementation of a checkpoint saver.
|
||||
*/
|
||||
public class MemoryCheckpointSaver implements BaseCheckpointSaver {
|
||||
private final Map<String, Map<String, Object>> checkpoints = new ConcurrentHashMap<>();
|
||||
private final Map<String, List<String>> threadCheckpoints = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public String checkpoint(String threadId, Map<String, Object> channelValues) {
|
||||
String checkpointId = ID.checkpointId(threadId);
|
||||
|
||||
// Store the checkpoint
|
||||
checkpoints.put(checkpointId, new HashMap<>(channelValues));
|
||||
|
||||
// Add to thread's checkpoints
|
||||
threadCheckpoints.computeIfAbsent(threadId, k ->
|
||||
Collections.synchronizedList(new ArrayList<>())).add(checkpointId);
|
||||
|
||||
return checkpointId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Map<String, Object>> getValues(String checkpointId) {
|
||||
Map<String, Object> values = checkpoints.get(checkpointId);
|
||||
return Optional.ofNullable(values).map(HashMap::new);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> list(String threadId) {
|
||||
List<String> result = threadCheckpoints.get(threadId);
|
||||
return result != null ? new ArrayList<>(result) : Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> latest(String threadId) {
|
||||
List<String> checkpoints = threadCheckpoints.get(threadId);
|
||||
|
||||
if (checkpoints == null || checkpoints.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(checkpoints.get(checkpoints.size() - 1));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String checkpointId) {
|
||||
// Remove the checkpoint
|
||||
Map<String, Object> removed = checkpoints.remove(checkpointId);
|
||||
|
||||
if (removed != null) {
|
||||
// Find and remove from thread's checkpoints
|
||||
for (List<String> checkpointsList : threadCheckpoints.values()) {
|
||||
checkpointsList.remove(checkpointId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear(String threadId) {
|
||||
List<String> checkpointIds = threadCheckpoints.remove(threadId);
|
||||
|
||||
if (checkpointIds != null) {
|
||||
// Remove all checkpoints for this thread
|
||||
for (String checkpointId : checkpointIds) {
|
||||
checkpoints.remove(checkpointId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
package com.langgraph.checkpoint.serde;
|
||||
|
||||
import org.msgpack.core.MessageBufferPacker;
|
||||
import org.msgpack.core.MessagePack;
|
||||
import org.msgpack.core.MessageUnpacker;
|
||||
import org.msgpack.core.MessageFormat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.*;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* MessagePack-based serializer that uses reflection to handle arbitrary Java objects.
|
||||
* Supports primitive types, collections, maps, records, and custom objects.
|
||||
*/
|
||||
public class MsgPackSerializer implements ReflectionSerializer {
|
||||
private final Map<Class<?>, TypeSerializer<?>> serializers = new ConcurrentHashMap<>();
|
||||
private final Map<Class<?>, TypeDeserializer<?>> deserializers = new ConcurrentHashMap<>();
|
||||
private final Map<Class<?>, RecordInfo> recordInfoCache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Record component information cache to avoid repeated reflection.
|
||||
*/
|
||||
private static class RecordInfo {
|
||||
final RecordComponent[] components;
|
||||
final Constructor<?> constructor;
|
||||
|
||||
RecordInfo(RecordComponent[] components, Constructor<?> constructor) {
|
||||
this.components = components;
|
||||
this.constructor = constructor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register built-in serializers for common types.
|
||||
*/
|
||||
public MsgPackSerializer() {
|
||||
registerBuiltinTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register built-in serializers for common types.
|
||||
*/
|
||||
private void registerBuiltinTypes() {
|
||||
// UUID serializer
|
||||
registerSerializer(UUID.class, (uuid) -> uuid.toString());
|
||||
registerDeserializer(UUID.class, (str) -> UUID.fromString((String) str));
|
||||
|
||||
// Date serializer
|
||||
registerSerializer(java.util.Date.class, (date) -> date.getTime());
|
||||
registerDeserializer(java.util.Date.class, (millis) -> new Date((Long) millis));
|
||||
|
||||
// Java 8 Date/Time API
|
||||
registerSerializer(Instant.class, (instant) -> instant.toString());
|
||||
registerDeserializer(Instant.class, (str) -> Instant.parse((String) str));
|
||||
|
||||
registerSerializer(LocalDate.class, (date) -> date.toString());
|
||||
registerDeserializer(LocalDate.class, (str) -> LocalDate.parse((String) str));
|
||||
|
||||
registerSerializer(LocalTime.class, (time) -> time.toString());
|
||||
registerDeserializer(LocalTime.class, (str) -> LocalTime.parse((String) str));
|
||||
|
||||
registerSerializer(LocalDateTime.class, (dateTime) -> dateTime.toString());
|
||||
registerDeserializer(LocalDateTime.class, (str) -> LocalDateTime.parse((String) str));
|
||||
|
||||
// Add more built-in serializers as needed
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> void registerSerializer(Class<T> type, TypeSerializer<T> serializer) {
|
||||
serializers.put(type, serializer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> void registerDeserializer(Class<T> type, TypeDeserializer<T> deserializer) {
|
||||
deserializers.put(type, deserializer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] serialize(Object obj) {
|
||||
try {
|
||||
MessageBufferPacker packer = MessagePack.newDefaultBufferPacker();
|
||||
serializeObject(obj, packer);
|
||||
return packer.toByteArray();
|
||||
} catch (IOException e) {
|
||||
throw new SerializationException("Failed to serialize object", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object deserialize(byte[] data) {
|
||||
try {
|
||||
MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(data);
|
||||
return deserializeObject(unpacker);
|
||||
} catch (IOException e) {
|
||||
throw new SerializationException("Failed to deserialize object", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an object to the MessagePack packer.
|
||||
*
|
||||
* @param obj Object to serialize
|
||||
* @param packer MessagePack packer
|
||||
* @throws IOException If packing fails
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void serializeObject(Object obj, MessageBufferPacker packer) throws IOException {
|
||||
if (obj == null) {
|
||||
packer.packNil();
|
||||
return;
|
||||
}
|
||||
|
||||
Class<?> type = obj.getClass();
|
||||
|
||||
// Check for registered serializer
|
||||
if (serializers.containsKey(type)) {
|
||||
TypeSerializer<Object> serializer = (TypeSerializer<Object>) serializers.get(type);
|
||||
Object serialized = serializer.toSerializable(obj);
|
||||
|
||||
// Pack as a special type
|
||||
packer.packMapHeader(2);
|
||||
packer.packString("__type__");
|
||||
packer.packString(type.getName());
|
||||
packer.packString("value");
|
||||
serializeObject(serialized, packer);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle primitive types and common objects directly
|
||||
if (obj instanceof String) {
|
||||
packer.packString((String) obj);
|
||||
} else if (obj instanceof Integer) {
|
||||
packer.packInt((Integer) obj);
|
||||
} else if (obj instanceof Long) {
|
||||
packer.packLong((Long) obj);
|
||||
} else if (obj instanceof Double) {
|
||||
packer.packDouble((Double) obj);
|
||||
} else if (obj instanceof Float) {
|
||||
packer.packFloat((Float) obj);
|
||||
} else if (obj instanceof Boolean) {
|
||||
packer.packBoolean((Boolean) obj);
|
||||
} else if (obj instanceof byte[]) {
|
||||
packer.packBinaryHeader(((byte[]) obj).length);
|
||||
packer.writePayload((byte[]) obj);
|
||||
} else if (obj instanceof List) {
|
||||
List<?> list = (List<?>) obj;
|
||||
packer.packArrayHeader(list.size());
|
||||
for (Object item : list) {
|
||||
serializeObject(item, packer);
|
||||
}
|
||||
} else if (obj instanceof Map) {
|
||||
Map<?, ?> map = (Map<?, ?>) obj;
|
||||
packer.packMapHeader(map.size());
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
serializeObject(entry.getKey(), packer);
|
||||
serializeObject(entry.getValue(), packer);
|
||||
}
|
||||
} else if (obj instanceof Enum<?>) {
|
||||
// Handle enums by name
|
||||
packer.packMapHeader(2);
|
||||
packer.packString("__type__");
|
||||
packer.packString(type.getName());
|
||||
packer.packString("value");
|
||||
packer.packString(((Enum<?>) obj).name());
|
||||
} else if (type.isRecord()) {
|
||||
// Handle Record types
|
||||
serializeRecord(obj, packer);
|
||||
} else {
|
||||
// Handle custom objects with reflection
|
||||
serializeCustomObject(obj, packer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a Record object.
|
||||
*
|
||||
* @param record The record to serialize
|
||||
* @param packer The MessagePack packer
|
||||
* @throws IOException If packing fails
|
||||
*/
|
||||
private void serializeRecord(Object record, MessageBufferPacker packer) throws IOException {
|
||||
Class<?> recordClass = record.getClass();
|
||||
|
||||
// Pack as a special type with fields
|
||||
packer.packMapHeader(2);
|
||||
packer.packString("__type__");
|
||||
packer.packString(recordClass.getName());
|
||||
packer.packString("fields");
|
||||
|
||||
RecordComponent[] components = recordClass.getRecordComponents();
|
||||
packer.packMapHeader(components.length);
|
||||
|
||||
for (RecordComponent component : components) {
|
||||
packer.packString(component.getName());
|
||||
try {
|
||||
Method accessor = component.getAccessor();
|
||||
Object value = accessor.invoke(record);
|
||||
serializeObject(value, packer);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new SerializationException("Failed to access record component: " + component.getName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a custom object using reflection.
|
||||
*
|
||||
* @param obj The object to serialize
|
||||
* @param packer The MessagePack packer
|
||||
* @throws IOException If packing fails
|
||||
*/
|
||||
private void serializeCustomObject(Object obj, MessageBufferPacker packer) throws IOException {
|
||||
Class<?> objClass = obj.getClass();
|
||||
|
||||
// Pack as a special type with fields
|
||||
packer.packMapHeader(2);
|
||||
packer.packString("__type__");
|
||||
packer.packString(objClass.getName());
|
||||
packer.packString("fields");
|
||||
|
||||
// Get all fields including inherited ones
|
||||
List<Field> fields = getAllFields(objClass);
|
||||
|
||||
// Filter out transient fields
|
||||
List<Field> serializableFields = fields.stream()
|
||||
.filter(field -> !Modifier.isTransient(field.getModifiers()) &&
|
||||
!Modifier.isStatic(field.getModifiers()))
|
||||
.toList();
|
||||
|
||||
packer.packMapHeader(serializableFields.size());
|
||||
|
||||
for (Field field : serializableFields) {
|
||||
packer.packString(field.getName());
|
||||
try {
|
||||
field.setAccessible(true);
|
||||
Object value = field.get(obj);
|
||||
serializeObject(value, packer);
|
||||
} catch (IllegalAccessException e) {
|
||||
throw new SerializationException("Failed to access field: " + field.getName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all fields for a class including inherited fields.
|
||||
*
|
||||
* @param clazz The class to get fields for
|
||||
* @return List of all fields
|
||||
*/
|
||||
private List<Field> getAllFields(Class<?> clazz) {
|
||||
List<Field> fields = new ArrayList<>();
|
||||
Class<?> currentClass = clazz;
|
||||
|
||||
while (currentClass != null && currentClass != Object.class) {
|
||||
fields.addAll(Arrays.asList(currentClass.getDeclaredFields()));
|
||||
currentClass = currentClass.getSuperclass();
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize an object from the MessagePack unpacker.
|
||||
*
|
||||
* @param unpacker MessagePack unpacker
|
||||
* @return Deserialized object
|
||||
* @throws IOException If unpacking fails
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Object deserializeObject(MessageUnpacker unpacker) throws IOException {
|
||||
if (!unpacker.hasNext()) {
|
||||
throw new SerializationException("Unexpected end of data");
|
||||
}
|
||||
|
||||
if (unpacker.tryUnpackNil()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
MessageFormat format = unpacker.getNextFormat();
|
||||
|
||||
if (format == MessageFormat.STR8 ||
|
||||
format == MessageFormat.STR16 ||
|
||||
format == MessageFormat.STR32 ||
|
||||
format == MessageFormat.FIXSTR) {
|
||||
return unpacker.unpackString();
|
||||
} else if (format == MessageFormat.INT8 ||
|
||||
format == MessageFormat.INT16 ||
|
||||
format == MessageFormat.INT32 ||
|
||||
format == MessageFormat.INT64 ||
|
||||
format == MessageFormat.UINT8 ||
|
||||
format == MessageFormat.UINT16 ||
|
||||
format == MessageFormat.UINT32 ||
|
||||
format == MessageFormat.UINT64 ||
|
||||
format == MessageFormat.POSFIXINT ||
|
||||
format == MessageFormat.NEGFIXINT) {
|
||||
if (format == MessageFormat.INT64 || format == MessageFormat.UINT64) {
|
||||
return unpacker.unpackLong();
|
||||
} else {
|
||||
try {
|
||||
return unpacker.unpackInt();
|
||||
} catch (Exception e) {
|
||||
// Fallback to long if int unpacking fails
|
||||
return unpacker.unpackLong();
|
||||
}
|
||||
}
|
||||
} else if (format == MessageFormat.FLOAT32 ||
|
||||
format == MessageFormat.FLOAT64) {
|
||||
return unpacker.unpackDouble();
|
||||
} else if (format == MessageFormat.BOOLEAN) {
|
||||
return unpacker.unpackBoolean();
|
||||
} else if (format == MessageFormat.BIN8 ||
|
||||
format == MessageFormat.BIN16 ||
|
||||
format == MessageFormat.BIN32) {
|
||||
int binaryLength = unpacker.unpackBinaryHeader();
|
||||
byte[] binary = new byte[binaryLength];
|
||||
unpacker.readPayload(binary);
|
||||
return binary;
|
||||
} else if (format == MessageFormat.ARRAY16 ||
|
||||
format == MessageFormat.ARRAY32 ||
|
||||
format == MessageFormat.FIXARRAY) {
|
||||
int arraySize = unpacker.unpackArrayHeader();
|
||||
List<Object> list = new ArrayList<>(arraySize);
|
||||
for (int i = 0; i < arraySize; i++) {
|
||||
list.add(deserializeObject(unpacker));
|
||||
}
|
||||
return list;
|
||||
} else if (format == MessageFormat.MAP16 ||
|
||||
format == MessageFormat.MAP32 ||
|
||||
format == MessageFormat.FIXMAP) {
|
||||
int mapSize = unpacker.unpackMapHeader();
|
||||
|
||||
// Handle empty map
|
||||
if (mapSize == 0) {
|
||||
return new HashMap<>();
|
||||
}
|
||||
|
||||
// Check for special type marker
|
||||
Object firstKey = deserializeObject(unpacker);
|
||||
if (mapSize == 2 && firstKey instanceof String && "__type__".equals(firstKey)) {
|
||||
String typeName = (String) deserializeObject(unpacker);
|
||||
|
||||
// Get the second key
|
||||
Object secondKey = deserializeObject(unpacker);
|
||||
|
||||
if (secondKey instanceof String) {
|
||||
String secondKeyStr = (String) secondKey;
|
||||
|
||||
try {
|
||||
Class<?> type = Class.forName(typeName);
|
||||
|
||||
// Check for registered deserializer
|
||||
if ("value".equals(secondKeyStr) && deserializers.containsKey(type)) {
|
||||
Object serialized = deserializeObject(unpacker);
|
||||
TypeDeserializer<Object> deserializer =
|
||||
(TypeDeserializer<Object>) deserializers.get(type);
|
||||
return deserializer.fromSerialized(serialized);
|
||||
}
|
||||
|
||||
// Handle enums
|
||||
if ("value".equals(secondKeyStr) && type.isEnum()) {
|
||||
String enumValue = (String) deserializeObject(unpacker);
|
||||
return Enum.valueOf((Class<Enum>) type, enumValue);
|
||||
}
|
||||
|
||||
// Handle records
|
||||
if ("fields".equals(secondKeyStr) && type.isRecord()) {
|
||||
return deserializeRecord(type, unpacker);
|
||||
}
|
||||
|
||||
// Handle custom objects
|
||||
if ("fields".equals(secondKeyStr)) {
|
||||
return deserializeCustomObject(type, unpacker);
|
||||
}
|
||||
} catch (ClassNotFoundException e) {
|
||||
// If class not found, fall back to regular map deserialization
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new SerializationException("Failed to deserialize object of type " + typeName, e);
|
||||
}
|
||||
|
||||
// If special type handling failed, read the value to keep unpacker consistent
|
||||
Object secondValue = deserializeObject(unpacker);
|
||||
|
||||
// Create a fallback map with the special type info
|
||||
Map<Object, Object> fallbackMap = new HashMap<>();
|
||||
fallbackMap.put(firstKey, typeName);
|
||||
fallbackMap.put(secondKey, secondValue);
|
||||
return fallbackMap;
|
||||
}
|
||||
|
||||
// If the second key wasn't a string as expected, we need to handle it as a regular map
|
||||
Object firstValue = deserializeObject(unpacker);
|
||||
|
||||
// Create a map with the first key-value pair
|
||||
Map<Object, Object> map = new HashMap<>(mapSize);
|
||||
map.put(firstKey, firstValue);
|
||||
|
||||
// Read the remaining entries
|
||||
for (int i = 1; i < mapSize; i++) {
|
||||
Object key = deserializeObject(unpacker);
|
||||
Object value = deserializeObject(unpacker);
|
||||
map.put(key, value);
|
||||
}
|
||||
|
||||
return map;
|
||||
} else {
|
||||
// Regular map - we already read the first key
|
||||
Map<Object, Object> map = new HashMap<>(mapSize);
|
||||
|
||||
// Read the first value
|
||||
Object firstValue = deserializeObject(unpacker);
|
||||
map.put(firstKey, firstValue);
|
||||
|
||||
// Read the remaining entries
|
||||
for (int i = 1; i < mapSize; i++) {
|
||||
Object key = deserializeObject(unpacker);
|
||||
Object value = deserializeObject(unpacker);
|
||||
map.put(key, value);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
// Default case
|
||||
throw new SerializationException("Unsupported MessagePack format: " + format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a record.
|
||||
*
|
||||
* @param recordClass The record class
|
||||
* @param unpacker The unpacker containing the fields map
|
||||
* @return The deserialized record
|
||||
* @throws IOException If unpacking fails
|
||||
* @throws ReflectiveOperationException If reflection operations fail
|
||||
*/
|
||||
private Object deserializeRecord(Class<?> recordClass, MessageUnpacker unpacker)
|
||||
throws IOException, ReflectiveOperationException {
|
||||
|
||||
// Get record info from cache or create it
|
||||
RecordInfo recordInfo = recordInfoCache.computeIfAbsent(recordClass, cls -> {
|
||||
try {
|
||||
RecordComponent[] components = cls.getRecordComponents();
|
||||
Class<?>[] paramTypes = Arrays.stream(components)
|
||||
.map(RecordComponent::getType)
|
||||
.toArray(Class<?>[]::new);
|
||||
Constructor<?> constructor = cls.getDeclaredConstructor(paramTypes);
|
||||
constructor.setAccessible(true);
|
||||
return new RecordInfo(components, constructor);
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new SerializationException("Failed to get constructor for record: " + cls.getName(), e);
|
||||
}
|
||||
});
|
||||
|
||||
// Read the fields map
|
||||
int fieldCount = unpacker.unpackMapHeader();
|
||||
Map<String, Object> fieldValues = new HashMap<>(fieldCount);
|
||||
|
||||
for (int i = 0; i < fieldCount; i++) {
|
||||
String fieldName = (String) deserializeObject(unpacker);
|
||||
Object fieldValue = deserializeObject(unpacker);
|
||||
fieldValues.put(fieldName, fieldValue);
|
||||
}
|
||||
|
||||
// Prepare constructor arguments in the correct order
|
||||
Object[] constructorArgs = new Object[recordInfo.components.length];
|
||||
for (int i = 0; i < recordInfo.components.length; i++) {
|
||||
RecordComponent component = recordInfo.components[i];
|
||||
Object value = fieldValues.get(component.getName());
|
||||
constructorArgs[i] = value;
|
||||
}
|
||||
|
||||
// Create the record instance
|
||||
return recordInfo.constructor.newInstance(constructorArgs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a custom object.
|
||||
*
|
||||
* @param objectClass The object class
|
||||
* @param unpacker The unpacker containing the fields map
|
||||
* @return The deserialized object
|
||||
* @throws IOException If unpacking fails
|
||||
* @throws ReflectiveOperationException If reflection operations fail
|
||||
*/
|
||||
private Object deserializeCustomObject(Class<?> objectClass, MessageUnpacker unpacker)
|
||||
throws IOException, ReflectiveOperationException {
|
||||
|
||||
// Create instance using default constructor
|
||||
Constructor<?> constructor;
|
||||
try {
|
||||
constructor = objectClass.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new SerializationException(
|
||||
"Class " + objectClass.getName() + " must have a no-arg constructor for deserialization", e);
|
||||
}
|
||||
|
||||
Object instance = constructor.newInstance();
|
||||
|
||||
// Read the fields map
|
||||
int fieldCount = unpacker.unpackMapHeader();
|
||||
|
||||
for (int i = 0; i < fieldCount; i++) {
|
||||
String fieldName = (String) deserializeObject(unpacker);
|
||||
Object fieldValue = deserializeObject(unpacker);
|
||||
|
||||
try {
|
||||
// Find the field (including in superclasses)
|
||||
Field field = findField(objectClass, fieldName);
|
||||
if (field != null) {
|
||||
field.setAccessible(true);
|
||||
field.set(instance, fieldValue);
|
||||
}
|
||||
} catch (NoSuchFieldException e) {
|
||||
// Skip fields that don't exist in the current class version
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a field in a class or its superclasses.
|
||||
*
|
||||
* @param clazz The class to search
|
||||
* @param fieldName The field name to find
|
||||
* @return The found field
|
||||
* @throws NoSuchFieldException If the field is not found
|
||||
*/
|
||||
private Field findField(Class<?> clazz, String fieldName) throws NoSuchFieldException {
|
||||
Class<?> currentClass = clazz;
|
||||
while (currentClass != null) {
|
||||
try {
|
||||
return currentClass.getDeclaredField(fieldName);
|
||||
} catch (NoSuchFieldException e) {
|
||||
currentClass = currentClass.getSuperclass();
|
||||
}
|
||||
}
|
||||
throw new NoSuchFieldException("Field not found: " + fieldName);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.langgraph.checkpoint.serde;
|
||||
|
||||
/**
|
||||
* Interface for a serializer that uses reflection to handle arbitrary Java objects.
|
||||
*/
|
||||
public interface ReflectionSerializer extends Serializer<Object> {
|
||||
/**
|
||||
* Register a custom serializer for a specific type.
|
||||
*
|
||||
* @param type Type to register
|
||||
* @param serializer Custom serializer for the type
|
||||
* @param <T> Type to register
|
||||
*/
|
||||
<T> void registerSerializer(Class<T> type, TypeSerializer<T> serializer);
|
||||
|
||||
/**
|
||||
* Register a custom deserializer for a specific type.
|
||||
*
|
||||
* @param type Type to register
|
||||
* @param deserializer Custom deserializer for the type
|
||||
* @param <T> Type to register
|
||||
*/
|
||||
<T> void registerDeserializer(Class<T> type, TypeDeserializer<T> deserializer);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.langgraph.checkpoint.serde;
|
||||
|
||||
/**
|
||||
* Exception thrown during serialization/deserialization.
|
||||
*/
|
||||
public class SerializationException extends RuntimeException {
|
||||
/**
|
||||
* Create a new serialization exception with a message.
|
||||
*
|
||||
* @param message Error message
|
||||
*/
|
||||
public SerializationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new serialization exception with a message and cause.
|
||||
*
|
||||
* @param message Error message
|
||||
* @param cause Underlying cause
|
||||
*/
|
||||
public SerializationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.langgraph.checkpoint.serde;
|
||||
|
||||
/**
|
||||
* Interface for serializing and deserializing objects.
|
||||
*
|
||||
* @param <T> Type of object to serialize/deserialize
|
||||
*/
|
||||
public interface Serializer<T> {
|
||||
/**
|
||||
* Serialize an object to bytes.
|
||||
*
|
||||
* @param obj The object to serialize
|
||||
* @return Serialized bytes
|
||||
*/
|
||||
byte[] serialize(T obj);
|
||||
|
||||
/**
|
||||
* Deserialize bytes to an object.
|
||||
*
|
||||
* @param data The bytes to deserialize
|
||||
* @return Deserialized object
|
||||
*/
|
||||
T deserialize(byte[] data);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.langgraph.checkpoint.serde;
|
||||
|
||||
/**
|
||||
* Interface for deserializing a specific type from MessagePack.
|
||||
*
|
||||
* @param <T> Type to deserialize
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface TypeDeserializer<T> {
|
||||
/**
|
||||
* Convert from serialized representation to object.
|
||||
*
|
||||
* @param serialized Serialized representation
|
||||
* @return Deserialized object
|
||||
*/
|
||||
T fromSerialized(Object serialized);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.langgraph.checkpoint.serde;
|
||||
|
||||
/**
|
||||
* Interface for serializing a specific type to a format that can be included in MessagePack.
|
||||
*
|
||||
* @param <T> Type to serialize
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface TypeSerializer<T> {
|
||||
/**
|
||||
* Convert object to a serializable representation.
|
||||
*
|
||||
* @param obj Object to convert
|
||||
* @return Serializable representation (must be compatible with MessagePack)
|
||||
*/
|
||||
Object toSerializable(T obj);
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.langgraph.checkpoint.base;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
public class IDTest {
|
||||
|
||||
@Test
|
||||
public void testUuidDeterministic() {
|
||||
// Same inputs should produce same UUIDs
|
||||
UUID uuid1 = ID.uuid("test", "value");
|
||||
UUID uuid2 = ID.uuid("test", "value");
|
||||
|
||||
assertThat(uuid1).isEqualTo(uuid2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUuidDifferentNamespace() {
|
||||
// Different namespaces should produce different UUIDs
|
||||
UUID uuid1 = ID.uuid("namespace1", "value");
|
||||
UUID uuid2 = ID.uuid("namespace2", "value");
|
||||
|
||||
assertThat(uuid1).isNotEqualTo(uuid2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUuidDifferentName() {
|
||||
// Different names should produce different UUIDs
|
||||
UUID uuid1 = ID.uuid("test", "value1");
|
||||
UUID uuid2 = ID.uuid("test", "value2");
|
||||
|
||||
assertThat(uuid1).isNotEqualTo(uuid2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCheckpointId() {
|
||||
// Checkpoint IDs should be valid UUIDs
|
||||
String id = ID.checkpointId("thread-123");
|
||||
|
||||
// Should be a valid UUID string
|
||||
UUID uuid = UUID.fromString(id);
|
||||
assertThat(uuid).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUrlSafeId() {
|
||||
// URL-safe IDs should be deterministic
|
||||
String id1 = ID.urlSafeId("test", "value");
|
||||
String id2 = ID.urlSafeId("test", "value");
|
||||
|
||||
assertThat(id1).isEqualTo(id2);
|
||||
|
||||
// Should not contain padding characters or unsafe URL characters
|
||||
assertThat(id1).doesNotContain("=", "+", "/");
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package com.langgraph.checkpoint.base.memory;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
public class AsyncMemoryCheckpointSaverTest {
|
||||
|
||||
private AsyncMemoryCheckpointSaver saver;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
saver = new AsyncMemoryCheckpointSaver();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCheckpointAsync() throws ExecutionException, InterruptedException {
|
||||
// Create test data
|
||||
String threadId = "test-thread";
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
values.put("key1", "value1");
|
||||
values.put("key2", 42);
|
||||
|
||||
// Create checkpoint asynchronously
|
||||
CompletableFuture<String> future = saver.checkpointAsync(threadId, values);
|
||||
|
||||
// Wait for completion
|
||||
String checkpointId = future.get();
|
||||
|
||||
// Verify checkpoint ID format (should be a UUID)
|
||||
assertThat(checkpointId).matches("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
|
||||
|
||||
// Verify thread has a checkpoint
|
||||
CompletableFuture<List<String>> listFuture = saver.listAsync(threadId);
|
||||
List<String> checkpoints = listFuture.get();
|
||||
assertThat(checkpoints).hasSize(1);
|
||||
assertThat(checkpoints).contains(checkpointId);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetValuesAsync() throws ExecutionException, InterruptedException {
|
||||
// Create test data
|
||||
String threadId = "test-thread";
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
values.put("key1", "value1");
|
||||
values.put("key2", 42);
|
||||
|
||||
// Create checkpoint
|
||||
String checkpointId = saver.checkpointAsync(threadId, values).get();
|
||||
|
||||
// Get values asynchronously
|
||||
CompletableFuture<Optional<Map<String, Object>>> future = saver.getValuesAsync(checkpointId);
|
||||
Optional<Map<String, Object>> retrievedValues = future.get();
|
||||
|
||||
// Verify values
|
||||
assertThat(retrievedValues).isPresent();
|
||||
assertThat(retrievedValues.get()).containsEntry("key1", "value1");
|
||||
assertThat(retrievedValues.get()).containsEntry("key2", 42);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testListAsync() throws ExecutionException, InterruptedException {
|
||||
// Create test data
|
||||
String threadId = "test-thread";
|
||||
|
||||
// Initially empty
|
||||
CompletableFuture<List<String>> initialFuture = saver.listAsync(threadId);
|
||||
List<String> initial = initialFuture.get();
|
||||
assertThat(initial).isEmpty();
|
||||
|
||||
// Create multiple checkpoints
|
||||
String id1 = saver.checkpointAsync(threadId, Map.of("key", "value1")).get();
|
||||
String id2 = saver.checkpointAsync(threadId, Map.of("key", "value2")).get();
|
||||
String id3 = saver.checkpointAsync(threadId, Map.of("key", "value3")).get();
|
||||
|
||||
// List checkpoints asynchronously
|
||||
CompletableFuture<List<String>> future = saver.listAsync(threadId);
|
||||
List<String> checkpoints = future.get();
|
||||
|
||||
// Verify order and content
|
||||
assertThat(checkpoints).hasSize(3);
|
||||
assertThat(checkpoints).containsExactly(id1, id2, id3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLatestAsync() throws ExecutionException, InterruptedException {
|
||||
// Create test data
|
||||
String threadId = "test-thread";
|
||||
|
||||
// Initially empty
|
||||
CompletableFuture<Optional<String>> initialFuture = saver.latestAsync(threadId);
|
||||
Optional<String> initial = initialFuture.get();
|
||||
assertThat(initial).isEmpty();
|
||||
|
||||
// Create multiple checkpoints
|
||||
saver.checkpointAsync(threadId, Map.of("key", "value1")).get();
|
||||
saver.checkpointAsync(threadId, Map.of("key", "value2")).get();
|
||||
String id3 = saver.checkpointAsync(threadId, Map.of("key", "value3")).get();
|
||||
|
||||
// Get latest asynchronously
|
||||
CompletableFuture<Optional<String>> future = saver.latestAsync(threadId);
|
||||
Optional<String> latest = future.get();
|
||||
|
||||
// Verify latest
|
||||
assertThat(latest).isPresent();
|
||||
assertThat(latest.get()).isEqualTo(id3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDeleteAsync() throws ExecutionException, InterruptedException {
|
||||
// Create test data
|
||||
String threadId = "test-thread";
|
||||
|
||||
// Create checkpoint
|
||||
String checkpointId = saver.checkpointAsync(threadId, Map.of("key", "value")).get();
|
||||
|
||||
// Verify checkpoint exists
|
||||
assertThat(saver.getValuesAsync(checkpointId).get()).isPresent();
|
||||
|
||||
// Delete checkpoint asynchronously
|
||||
CompletableFuture<Void> future = saver.deleteAsync(checkpointId);
|
||||
future.get(); // Wait for completion
|
||||
|
||||
// Verify checkpoint is deleted
|
||||
assertThat(saver.getValuesAsync(checkpointId).get()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testClearAsync() throws ExecutionException, InterruptedException {
|
||||
// Create test data
|
||||
String threadId = "test-thread";
|
||||
|
||||
// Create multiple checkpoints
|
||||
String id1 = saver.checkpointAsync(threadId, Map.of("key", "value1")).get();
|
||||
String id2 = saver.checkpointAsync(threadId, Map.of("key", "value2")).get();
|
||||
|
||||
// Verify checkpoints exist
|
||||
assertThat(saver.listAsync(threadId).get()).hasSize(2);
|
||||
|
||||
// Clear thread asynchronously
|
||||
CompletableFuture<Void> future = saver.clearAsync(threadId);
|
||||
future.get(); // Wait for completion
|
||||
|
||||
// Verify checkpoints are deleted
|
||||
assertThat(saver.listAsync(threadId).get()).isEmpty();
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package com.langgraph.checkpoint.base.memory;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
public class MemoryCheckpointSaverTest {
|
||||
|
||||
private MemoryCheckpointSaver saver;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
saver = new MemoryCheckpointSaver();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCheckpoint() {
|
||||
// Create test data
|
||||
String threadId = "test-thread";
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
values.put("key1", "value1");
|
||||
values.put("key2", 42);
|
||||
|
||||
// Create checkpoint
|
||||
String checkpointId = saver.checkpoint(threadId, values);
|
||||
|
||||
// Verify checkpoint ID format (should be a UUID)
|
||||
assertThat(checkpointId).matches("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
|
||||
|
||||
// Verify thread has a checkpoint
|
||||
List<String> checkpoints = saver.list(threadId);
|
||||
assertThat(checkpoints).hasSize(1);
|
||||
assertThat(checkpoints).contains(checkpointId);
|
||||
|
||||
// Verify latest checkpoint
|
||||
Optional<String> latest = saver.latest(threadId);
|
||||
assertThat(latest).isPresent();
|
||||
assertThat(latest.get()).isEqualTo(checkpointId);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetValues() {
|
||||
// Create test data
|
||||
String threadId = "test-thread";
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
values.put("key1", "value1");
|
||||
values.put("key2", 42);
|
||||
|
||||
// Create checkpoint
|
||||
String checkpointId = saver.checkpoint(threadId, values);
|
||||
|
||||
// Get values
|
||||
Optional<Map<String, Object>> retrievedValues = saver.getValues(checkpointId);
|
||||
|
||||
// Verify values
|
||||
assertThat(retrievedValues).isPresent();
|
||||
assertThat(retrievedValues.get()).containsEntry("key1", "value1");
|
||||
assertThat(retrievedValues.get()).containsEntry("key2", 42);
|
||||
|
||||
// Verify non-existent checkpoint
|
||||
Optional<Map<String, Object>> nonExistent = saver.getValues("non-existent");
|
||||
assertThat(nonExistent).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testList() {
|
||||
// Create test data
|
||||
String threadId = "test-thread";
|
||||
|
||||
// Initially empty
|
||||
List<String> initial = saver.list(threadId);
|
||||
assertThat(initial).isEmpty();
|
||||
|
||||
// Create multiple checkpoints
|
||||
String id1 = saver.checkpoint(threadId, Map.of("key", "value1"));
|
||||
String id2 = saver.checkpoint(threadId, Map.of("key", "value2"));
|
||||
String id3 = saver.checkpoint(threadId, Map.of("key", "value3"));
|
||||
|
||||
// List checkpoints
|
||||
List<String> checkpoints = saver.list(threadId);
|
||||
|
||||
// Verify order and content
|
||||
assertThat(checkpoints).hasSize(3);
|
||||
assertThat(checkpoints).containsExactly(id1, id2, id3);
|
||||
|
||||
// Different thread should have no checkpoints
|
||||
List<String> otherThread = saver.list("other-thread");
|
||||
assertThat(otherThread).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testLatest() {
|
||||
// Create test data
|
||||
String threadId = "test-thread";
|
||||
|
||||
// Initially empty
|
||||
Optional<String> initial = saver.latest(threadId);
|
||||
assertThat(initial).isEmpty();
|
||||
|
||||
// Create multiple checkpoints
|
||||
saver.checkpoint(threadId, Map.of("key", "value1"));
|
||||
saver.checkpoint(threadId, Map.of("key", "value2"));
|
||||
String id3 = saver.checkpoint(threadId, Map.of("key", "value3"));
|
||||
|
||||
// Get latest
|
||||
Optional<String> latest = saver.latest(threadId);
|
||||
|
||||
// Verify latest
|
||||
assertThat(latest).isPresent();
|
||||
assertThat(latest.get()).isEqualTo(id3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDelete() {
|
||||
// Create test data
|
||||
String threadId = "test-thread";
|
||||
|
||||
// Create checkpoint
|
||||
String checkpointId = saver.checkpoint(threadId, Map.of("key", "value"));
|
||||
|
||||
// Verify checkpoint exists
|
||||
assertThat(saver.getValues(checkpointId)).isPresent();
|
||||
assertThat(saver.list(threadId)).contains(checkpointId);
|
||||
|
||||
// Delete checkpoint
|
||||
saver.delete(checkpointId);
|
||||
|
||||
// Verify checkpoint is deleted
|
||||
assertThat(saver.getValues(checkpointId)).isEmpty();
|
||||
assertThat(saver.list(threadId)).doesNotContain(checkpointId);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testClear() {
|
||||
// Create test data
|
||||
String threadId = "test-thread";
|
||||
|
||||
// Create multiple checkpoints
|
||||
String id1 = saver.checkpoint(threadId, Map.of("key", "value1"));
|
||||
String id2 = saver.checkpoint(threadId, Map.of("key", "value2"));
|
||||
|
||||
// Verify checkpoints exist
|
||||
assertThat(saver.list(threadId)).hasSize(2);
|
||||
assertThat(saver.getValues(id1)).isPresent();
|
||||
assertThat(saver.getValues(id2)).isPresent();
|
||||
|
||||
// Clear thread
|
||||
saver.clear(threadId);
|
||||
|
||||
// Verify checkpoints are deleted
|
||||
assertThat(saver.list(threadId)).isEmpty();
|
||||
assertThat(saver.getValues(id1)).isEmpty();
|
||||
assertThat(saver.getValues(id2)).isEmpty();
|
||||
}
|
||||
}
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
package com.langgraph.checkpoint.serde;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.*;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.within;
|
||||
|
||||
public class MsgPackSerializerTest {
|
||||
|
||||
private MsgPackSerializer serializer;
|
||||
|
||||
@BeforeEach
|
||||
public void setUp() {
|
||||
serializer = new MsgPackSerializer();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSerializeDeserializePrimitives() {
|
||||
// Test with various primitive types
|
||||
assertRoundTrip("Test string");
|
||||
assertRoundTrip(123);
|
||||
assertRoundTrip(123456789L);
|
||||
assertRoundTrip(123.45);
|
||||
assertRoundTrip(123.45f);
|
||||
assertRoundTrip(true);
|
||||
assertRoundTrip(false);
|
||||
assertRoundTrip(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSerializeDeserializeArrays() {
|
||||
// Test with arrays and collections
|
||||
assertRoundTrip(new byte[] {1, 2, 3, 4, 5});
|
||||
assertRoundTrip(Arrays.asList("one", "two", "three"));
|
||||
assertRoundTrip(Arrays.asList(1, 2, 3, 4, 5));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSerializeDeserializeMaps() {
|
||||
// Test with maps
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("string", "value");
|
||||
map.put("int", 123);
|
||||
map.put("boolean", true);
|
||||
|
||||
assertRoundTrip(map);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSerializeDeserializeNestedStructures() {
|
||||
// Test with nested structures
|
||||
Map<String, Object> nested = new HashMap<>();
|
||||
nested.put("list", Arrays.asList(1, 2, 3));
|
||||
nested.put("map", Map.of("key", "value"));
|
||||
|
||||
assertRoundTrip(nested);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSerializeDeserializeEnums() {
|
||||
// Test with enums
|
||||
assertRoundTrip(TestEnum.VALUE1);
|
||||
assertRoundTrip(TestEnum.VALUE2);
|
||||
assertRoundTrip(TestEnum.VALUE3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSerializeDeserializeRecord() {
|
||||
// Test with a record
|
||||
TestRecord record = new TestRecord("test", 123, Arrays.asList("a", "b", "c"));
|
||||
|
||||
// Serialize and deserialize
|
||||
byte[] serialized = serializer.serialize(record);
|
||||
Object deserialized = serializer.deserialize(serialized);
|
||||
|
||||
// Verify
|
||||
assertThat(deserialized).isInstanceOf(TestRecord.class);
|
||||
TestRecord deserializedRecord = (TestRecord) deserialized;
|
||||
assertThat(deserializedRecord.name()).isEqualTo("test");
|
||||
assertThat(deserializedRecord.value()).isEqualTo(123);
|
||||
assertThat(deserializedRecord.tags()).containsExactly("a", "b", "c");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSerializeDeserializeNestedRecord() {
|
||||
// Test with a nested record
|
||||
NestedTestRecord record = new NestedTestRecord(
|
||||
"parent",
|
||||
new TestRecord("child", 456, Arrays.asList("x", "y", "z"))
|
||||
);
|
||||
|
||||
// Serialize and deserialize
|
||||
byte[] serialized = serializer.serialize(record);
|
||||
Object deserialized = serializer.deserialize(serialized);
|
||||
|
||||
// Verify
|
||||
assertThat(deserialized).isInstanceOf(NestedTestRecord.class);
|
||||
NestedTestRecord deserializedRecord = (NestedTestRecord) deserialized;
|
||||
assertThat(deserializedRecord.name()).isEqualTo("parent");
|
||||
assertThat(deserializedRecord.child()).isInstanceOf(TestRecord.class);
|
||||
assertThat(deserializedRecord.child().name()).isEqualTo("child");
|
||||
assertThat(deserializedRecord.child().value()).isEqualTo(456);
|
||||
assertThat(deserializedRecord.child().tags()).containsExactly("x", "y", "z");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSerializeDeserializeCustomObject() {
|
||||
// Test with a custom object
|
||||
TestObject obj = new TestObject();
|
||||
obj.setName("test");
|
||||
obj.setValue(123);
|
||||
obj.setActive(true);
|
||||
|
||||
// Serialize and deserialize
|
||||
byte[] serialized = serializer.serialize(obj);
|
||||
Object deserialized = serializer.deserialize(serialized);
|
||||
|
||||
// Verify
|
||||
assertThat(deserialized).isInstanceOf(TestObject.class);
|
||||
TestObject deserializedObj = (TestObject) deserialized;
|
||||
assertThat(deserializedObj.getName()).isEqualTo("test");
|
||||
assertThat(deserializedObj.getValue()).isEqualTo(123);
|
||||
assertThat(deserializedObj.isActive()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSerializeDeserializeInheritance() {
|
||||
// Test with inheritance
|
||||
ChildTestObject obj = new ChildTestObject();
|
||||
obj.setName("parent");
|
||||
obj.setValue(123);
|
||||
obj.setActive(true);
|
||||
obj.setChildProperty("child");
|
||||
obj.setChildValue(456);
|
||||
|
||||
// Serialize and deserialize
|
||||
byte[] serialized = serializer.serialize(obj);
|
||||
Object deserialized = serializer.deserialize(serialized);
|
||||
|
||||
// Verify
|
||||
assertThat(deserialized).isInstanceOf(ChildTestObject.class);
|
||||
ChildTestObject deserializedObj = (ChildTestObject) deserialized;
|
||||
assertThat(deserializedObj.getName()).isEqualTo("parent");
|
||||
assertThat(deserializedObj.getValue()).isEqualTo(123);
|
||||
assertThat(deserializedObj.isActive()).isTrue();
|
||||
assertThat(deserializedObj.getChildProperty()).isEqualTo("child");
|
||||
assertThat(deserializedObj.getChildValue()).isEqualTo(456);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSerializeDeserializeWithCustomSerializer() {
|
||||
// Register custom UUID serializer (although built-in one exists)
|
||||
serializer.registerSerializer(UUID.class, (uuid) -> uuid.toString().replace("-", ""));
|
||||
serializer.registerDeserializer(UUID.class, (str) -> {
|
||||
String uuidStr = (String) str;
|
||||
// Insert hyphens for standard UUID format
|
||||
uuidStr = uuidStr.replaceFirst(
|
||||
"(\\p{XDigit}{8})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}+)",
|
||||
"$1-$2-$3-$4-$5");
|
||||
return UUID.fromString(uuidStr);
|
||||
});
|
||||
|
||||
// Test with UUID
|
||||
UUID uuid = UUID.randomUUID();
|
||||
|
||||
// Serialize and deserialize
|
||||
byte[] serialized = serializer.serialize(uuid);
|
||||
Object deserialized = serializer.deserialize(serialized);
|
||||
|
||||
// Verify
|
||||
assertThat(deserialized).isInstanceOf(UUID.class);
|
||||
assertThat(deserialized).isEqualTo(uuid);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSerializeDeserializeDateTypes() {
|
||||
// Test with Date
|
||||
Date date = new Date();
|
||||
|
||||
// Serialize and deserialize
|
||||
byte[] serialized = serializer.serialize(date);
|
||||
Object deserialized = serializer.deserialize(serialized);
|
||||
|
||||
// Verify
|
||||
assertThat(deserialized).isInstanceOf(Date.class);
|
||||
assertThat(deserialized).isEqualTo(date);
|
||||
|
||||
// Test with Java 8 Date/Time types
|
||||
Instant instant = Instant.now();
|
||||
LocalDate localDate = LocalDate.now();
|
||||
LocalTime localTime = LocalTime.now();
|
||||
LocalDateTime localDateTime = LocalDateTime.now();
|
||||
|
||||
assertRoundTrip(instant);
|
||||
assertRoundTrip(localDate);
|
||||
assertRoundTrip(localTime);
|
||||
assertRoundTrip(localDateTime);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testTransientFields() {
|
||||
// Test with transient fields
|
||||
ObjectWithTransient obj = new ObjectWithTransient();
|
||||
obj.setPersistent("saved");
|
||||
obj.setTransientField("not-saved");
|
||||
|
||||
// Serialize and deserialize
|
||||
byte[] serialized = serializer.serialize(obj);
|
||||
Object deserialized = serializer.deserialize(serialized);
|
||||
|
||||
// Verify
|
||||
assertThat(deserialized).isInstanceOf(ObjectWithTransient.class);
|
||||
ObjectWithTransient deserializedObj = (ObjectWithTransient) deserialized;
|
||||
assertThat(deserializedObj.getPersistent()).isEqualTo("saved");
|
||||
assertThat(deserializedObj.getTransientField()).isNull(); // Should be null after deserialization
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to assert that an object survives a round trip through serialization.
|
||||
*
|
||||
* @param obj Object to test
|
||||
*/
|
||||
private void assertRoundTrip(Object obj) {
|
||||
try {
|
||||
// Serialize
|
||||
byte[] serialized = serializer.serialize(obj);
|
||||
|
||||
// Deserialize
|
||||
Object deserialized = serializer.deserialize(serialized);
|
||||
|
||||
// Verify
|
||||
if (obj instanceof byte[]) {
|
||||
// Arrays need special comparison
|
||||
assertThat(deserialized).isInstanceOf(byte[].class);
|
||||
assertThat((byte[]) deserialized).isEqualTo((byte[]) obj);
|
||||
} else if (obj instanceof Number) {
|
||||
// For any number type, compare by value instead of exact type
|
||||
if (deserialized instanceof Number) {
|
||||
double expected = ((Number) obj).doubleValue();
|
||||
double actual = ((Number) deserialized).doubleValue();
|
||||
assertThat(actual).isCloseTo(expected, within(0.0001));
|
||||
} else {
|
||||
throw new AssertionError("Expected Number, got " +
|
||||
(deserialized != null ? deserialized.getClass().getName() : "null"));
|
||||
}
|
||||
} else {
|
||||
// Special handling for lists
|
||||
if (obj instanceof List && deserialized instanceof List) {
|
||||
List<?> originalList = (List<?>) obj;
|
||||
List<?> deserializedList = (List<?>) deserialized;
|
||||
assertThat(deserializedList).hasSameSizeAs(originalList);
|
||||
|
||||
// Check each element
|
||||
for (int i = 0; i < originalList.size(); i++) {
|
||||
Object originalItem = originalList.get(i);
|
||||
Object deserializedItem = deserializedList.get(i);
|
||||
|
||||
if (originalItem instanceof Number && deserializedItem instanceof Number) {
|
||||
// Compare numbers by value instead of exact type
|
||||
assertThat(((Number) deserializedItem).doubleValue())
|
||||
.isCloseTo(((Number) originalItem).doubleValue(), within(0.0001));
|
||||
} else {
|
||||
assertThat(deserializedItem).isEqualTo(originalItem);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular equality for other types
|
||||
assertThat(deserialized).isEqualTo(obj);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new AssertionError("Error in roundtrip for " + obj + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test enum.
|
||||
*/
|
||||
public enum TestEnum {
|
||||
VALUE1, VALUE2, VALUE3
|
||||
}
|
||||
|
||||
/**
|
||||
* Test record class.
|
||||
*/
|
||||
public record TestRecord(String name, int value, List<String> tags) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested test record class.
|
||||
*/
|
||||
public record NestedTestRecord(String name, TestRecord child) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test class for custom object serialization.
|
||||
*/
|
||||
public static class TestObject {
|
||||
private String name;
|
||||
private int value;
|
||||
private boolean active;
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public int getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public void setValue(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
this.active = active;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Child test class for inheritance testing.
|
||||
*/
|
||||
public static class ChildTestObject extends TestObject {
|
||||
private String childProperty;
|
||||
private int childValue;
|
||||
|
||||
public String getChildProperty() {
|
||||
return childProperty;
|
||||
}
|
||||
|
||||
public void setChildProperty(String childProperty) {
|
||||
this.childProperty = childProperty;
|
||||
}
|
||||
|
||||
public int getChildValue() {
|
||||
return childValue;
|
||||
}
|
||||
|
||||
public void setChildValue(int childValue) {
|
||||
this.childValue = childValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test class with transient fields.
|
||||
*/
|
||||
public static class ObjectWithTransient {
|
||||
private String persistent;
|
||||
private transient String transientField;
|
||||
|
||||
public String getPersistent() {
|
||||
return persistent;
|
||||
}
|
||||
|
||||
public void setPersistent(String persistent) {
|
||||
this.persistent = persistent;
|
||||
}
|
||||
|
||||
public String getTransientField() {
|
||||
return transientField;
|
||||
}
|
||||
|
||||
public void setTransientField(String transientField) {
|
||||
this.transientField = transientField;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user