Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
checks:
name: fmt + clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- uses: Swatinem/rust-cache@v2

- name: Check formatting
run: cargo fmt --all -- --check
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,4 @@ crates/langgraph-tracing/frontend/node_modules/*
*db
publish.sh
publish.ps1
.codebuddy/*
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,13 @@ Or configure it in your `Cargo.toml` manually:
```toml
[dependencies]
# Basic core package
langgraph = "0.2.1"
langgraph = "0.2.5"

# Or enable specific features:
# langgraph = { version = "0.2.1", features = ["prebuilt", "providers", "sqlite", "postgres"] }
# langgraph = { version = "0.2.5", features = ["prebuilt", "providers", "sqlite", "postgres"] }

# Or enable all features at once:
# langgraph = { version = "0.2.1", features = ["full"] }
# langgraph = { version = "0.2.5", features = ["full"] }
```

### Cargo Features
Expand Down
2 changes: 1 addition & 1 deletion crates/langgraph-checkpoint-postgres/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Postgres checkpoint saver implementation using sqlx.

pub mod saver;
pub mod queries;
pub mod saver;

pub use saver::PostgresSaver;
42 changes: 27 additions & 15 deletions crates/langgraph-checkpoint-postgres/src/saver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ use serde_json::Value as JsonValue;
use sqlx::postgres::{PgPool, PgPoolOptions, PgRow};
use sqlx::Row;

use langgraph_checkpoint::checkpoint::base::{get_checkpoint_id, writes_idx_map, BaseCheckpointSaver};
use langgraph_checkpoint::checkpoint::base::{
get_checkpoint_id, writes_idx_map, BaseCheckpointSaver,
};
use langgraph_checkpoint::checkpoint::types::*;
use langgraph_checkpoint::config::RunnableConfig;
use langgraph_checkpoint::error::CheckpointError;
Expand All @@ -19,7 +21,17 @@ use crate::queries::*;
type BlobRow = (String, String, String, String, String, Option<Vec<u8>>);

/// Write row: (thread_id, checkpoint_ns, checkpoint_id, task_id, task_path, idx, channel, type_tag, blob)
type WriteRow = (String, String, String, String, String, i32, String, String, Vec<u8>);
type WriteRow = (
String,
String,
String,
String,
String,
i32,
String,
String,
Vec<u8>,
);

/// Helper: create a RunnableConfig from a JSON value.
fn config_from_json(val: serde_json::Value) -> RunnableConfig {
Expand All @@ -35,7 +47,11 @@ fn any_to_json(val: Box<dyn std::any::Any + Send + Sync>) -> JsonValue {
JsonValue::String(*val.downcast::<String>().unwrap())
} else if val.is::<Vec<u8>>() {
let b = val.downcast::<Vec<u8>>().unwrap();
JsonValue::Array(b.into_iter().map(|byte: u8| JsonValue::Number(byte.into())).collect())
JsonValue::Array(
b.into_iter()
.map(|byte: u8| JsonValue::Number(byte.into()))
.collect(),
)
} else {
// () and unknown types both map to Null
JsonValue::Null
Expand Down Expand Up @@ -79,12 +95,11 @@ impl PostgresSaver {
.await
.map_err(|e| CheckpointError::Storage(e.to_string()))?;

let row: Option<(i32,)> = sqlx::query_as(
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1",
)
.fetch_optional(&self.pool)
.await
.map_err(|e| CheckpointError::Storage(e.to_string()))?;
let row: Option<(i32,)> =
sqlx::query_as("SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1")
.fetch_optional(&self.pool)
.await
.map_err(|e| CheckpointError::Storage(e.to_string()))?;

let version = row.map(|(v,)| v).unwrap_or(-1);

Expand Down Expand Up @@ -220,10 +235,7 @@ impl PostgresSaver {
.iter()
.enumerate()
.filter_map(|(idx, (_task_id, channel, value))| {
let idx_val = idx_map
.get(channel.as_str())
.copied()
.unwrap_or(idx as i64) as i32;
let idx_val = idx_map.get(channel.as_str()).copied().unwrap_or(idx as i64) as i32;
if let Ok((type_tag, blob)) = self.serde.dumps_typed(value) {
Some((
thread_id.to_string(),
Expand Down Expand Up @@ -453,8 +465,8 @@ impl BaseCheckpointSaver for PostgresSaver {

let checkpoint_json = serde_json::to_value(checkpoint)
.map_err(|e| CheckpointError::Storage(e.to_string()))?;
let metadata_json = serde_json::to_value(metadata)
.map_err(|e| CheckpointError::Storage(e.to_string()))?;
let metadata_json =
serde_json::to_value(metadata).map_err(|e| CheckpointError::Storage(e.to_string()))?;

// Upsert blobs
let blobs = self.dump_blobs(
Expand Down
55 changes: 36 additions & 19 deletions crates/langgraph-checkpoint-sqlite/src/saver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ use std::sync::Arc;

use async_trait::async_trait;
use serde_json::Value as JsonValue;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow};
use sqlx::sqlite::{
SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow,
};
use sqlx::Row;

use langgraph_checkpoint::checkpoint::base::{
Expand Down Expand Up @@ -69,12 +71,11 @@ impl SqliteSaver {
.await
.map_err(|e| CheckpointError::Storage(e.to_string()))?;

let row: Option<(i64,)> = sqlx::query_as(
"SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1",
)
.fetch_optional(&self.pool)
.await
.map_err(|e| CheckpointError::Storage(e.to_string()))?;
let row: Option<(i64,)> =
sqlx::query_as("SELECT v FROM checkpoint_migrations ORDER BY v DESC LIMIT 1")
.fetch_optional(&self.pool)
.await
.map_err(|e| CheckpointError::Storage(e.to_string()))?;

let version = row.map(|(v,)| v).unwrap_or(-1);

Expand Down Expand Up @@ -128,9 +129,8 @@ impl SqliteSaver {
.map_err(|e| CheckpointError::Storage(e.to_string()))?;

let parent_checkpoint_id: Option<String> = row.try_get("parent_checkpoint_id").ok();
let parent_config = parent_checkpoint_id.map(|pid| {
Self::make_config(&thread_id, &checkpoint_ns, &pid)
});
let parent_config =
parent_checkpoint_id.map(|pid| Self::make_config(&thread_id, &checkpoint_ns, &pid));

let tuple_config = Self::make_config(&thread_id, &checkpoint_ns, &checkpoint.id);

Expand Down Expand Up @@ -408,7 +408,11 @@ fn any_to_json(val: Box<dyn std::any::Any>) -> JsonValue {
JsonValue::String(*val.downcast::<String>().unwrap())
} else if val.is::<Vec<u8>>() {
let b = val.downcast::<Vec<u8>>().unwrap();
JsonValue::Array(b.into_iter().map(|byte: u8| JsonValue::Number(byte.into())).collect())
JsonValue::Array(
b.into_iter()
.map(|byte: u8| JsonValue::Number(byte.into()))
.collect(),
)
} else {
JsonValue::Null
}
Expand Down Expand Up @@ -546,7 +550,10 @@ impl BaseCheckpointSaver for SqliteSaver {
let mut checkpoint_value = serde_json::to_value(checkpoint)
.map_err(|e| CheckpointError::Storage(e.to_string()))?;
if let Some(obj) = checkpoint_value.as_object_mut() {
obj.insert("channel_values".to_string(), JsonValue::Object(Default::default()));
obj.insert(
"channel_values".to_string(),
JsonValue::Object(Default::default()),
);
}
let checkpoint_text = serde_json::to_string(&checkpoint_value)
.map_err(|e| CheckpointError::Storage(e.to_string()))?;
Expand Down Expand Up @@ -652,10 +659,7 @@ impl BaseCheckpointSaver for SqliteSaver {
.map_err(|e| CheckpointError::Storage(e.to_string()))?;

for (idx, (_task_id_in_tuple, channel, value)) in writes.iter().enumerate() {
let idx_val: i64 = idx_map
.get(channel.as_str())
.copied()
.unwrap_or(idx as i64);
let idx_val: i64 = idx_map.get(channel.as_str()).copied().unwrap_or(idx as i64);

let (type_tag, blob) = match self.serde.dumps_typed(value) {
Ok(pair) => pair,
Expand Down Expand Up @@ -825,8 +829,16 @@ mod tests {

let cfg_with_id = config_with_id("thread-W", &cp.id);
let writes = vec![
("ch1".to_string(), "task-1".to_string(), serde_json::json!("v1")),
("ch2".to_string(), "task-1".to_string(), serde_json::json!(42)),
(
"ch1".to_string(),
"task-1".to_string(),
serde_json::json!("v1"),
),
(
"ch2".to_string(),
"task-1".to_string(),
serde_json::json!(42),
),
];
saver
.aput_writes(&cfg_with_id, writes, "task-1".into(), "".into())
Expand Down Expand Up @@ -1071,7 +1083,12 @@ mod tests {
let cp_clone = cp.clone();
let vers_clone = vers.clone();
let put_result = tokio::task::spawn_blocking(move || {
s2.put(&cfg2, &cp_clone, &CheckpointMetadata::default(), &vers_clone)
s2.put(
&cfg2,
&cp_clone,
&CheckpointMetadata::default(),
&vers_clone,
)
})
.await
.unwrap();
Expand Down
19 changes: 14 additions & 5 deletions crates/langgraph-checkpoint/src/cache/base.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use crate::error::CheckpointError;
use async_trait::async_trait;
use serde_json::Value as JsonValue;
use crate::error::CheckpointError;
use std::collections::HashMap;

/// Cache namespace: tuple of namespace segments
pub type CacheNamespace = Vec<String>;
Expand All @@ -13,7 +13,10 @@ pub type FullKey = (CacheNamespace, String);
#[async_trait]
pub trait BaseCache: Send + Sync {
/// Get cached values by keys
fn get(&self, keys: &[(CacheNamespace, String)]) -> Result<HashMap<FullKey, JsonValue>, CheckpointError>;
fn get(
&self,
keys: &[(CacheNamespace, String)],
) -> Result<HashMap<FullKey, JsonValue>, CheckpointError>;

/// Set cached values with optional TTL (in seconds)
fn set(&self, pairs: &[(FullKey, JsonValue, Option<i64>)]) -> Result<(), CheckpointError>;
Expand All @@ -23,11 +26,17 @@ pub trait BaseCache: Send + Sync {

// Async mirrors

async fn aget(&self, keys: Vec<(CacheNamespace, String)>) -> Result<HashMap<FullKey, JsonValue>, CheckpointError> {
async fn aget(
&self,
keys: Vec<(CacheNamespace, String)>,
) -> Result<HashMap<FullKey, JsonValue>, CheckpointError> {
self.get(&keys)
}

async fn aset(&self, pairs: Vec<(FullKey, JsonValue, Option<i64>)>) -> Result<(), CheckpointError> {
async fn aset(
&self,
pairs: Vec<(FullKey, JsonValue, Option<i64>)>,
) -> Result<(), CheckpointError> {
self.set(&pairs)
}

Expand Down
32 changes: 21 additions & 11 deletions crates/langgraph-checkpoint/src/cache/memory.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use std::collections::HashMap;
use super::base::*;
use crate::error::CheckpointError;
use async_trait::async_trait;
use parking_lot::RwLock;
use serde_json::Value as JsonValue;
use async_trait::async_trait;
use crate::error::CheckpointError;
use super::base::*;
use std::collections::HashMap;

/// In-memory cache implementation
pub struct InMemoryCache {
Expand All @@ -27,7 +27,10 @@ impl Default for InMemoryCache {

#[async_trait]
impl BaseCache for InMemoryCache {
fn get(&self, keys: &[(CacheNamespace, String)]) -> Result<HashMap<FullKey, JsonValue>, CheckpointError> {
fn get(
&self,
keys: &[(CacheNamespace, String)],
) -> Result<HashMap<FullKey, JsonValue>, CheckpointError> {
let cache = self.cache.read();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
Expand Down Expand Up @@ -60,10 +63,11 @@ impl BaseCache for InMemoryCache {
.as_secs_f64();

for ((namespace, key), value, ttl_secs) in pairs {
let bytes = serde_json::to_vec(value)
.map_err(|e| CheckpointError::Storage(e.to_string()))?;
let bytes =
serde_json::to_vec(value).map_err(|e| CheckpointError::Storage(e.to_string()))?;
let expire_at = ttl_secs.map(|ttl| now + ttl as f64);
cache.entry(namespace.clone())
cache
.entry(namespace.clone())
.or_default()
.insert(key.clone(), ("json".to_string(), bytes, expire_at));
}
Expand Down Expand Up @@ -97,7 +101,9 @@ mod tests {
let key = "k1".to_string();

// Set
cache.set(&[((ns.clone(), key.clone()), serde_json::json!("hello"), None)]).unwrap();
cache
.set(&[((ns.clone(), key.clone()), serde_json::json!("hello"), None)])
.unwrap();

// Get
let result = cache.get(&[(ns.clone(), key.clone())]).unwrap();
Expand All @@ -108,15 +114,19 @@ mod tests {
#[test]
fn test_cache_miss() {
let cache = InMemoryCache::new();
let result = cache.get(&[(vec!["ns".to_string()], "missing".to_string())]).unwrap();
let result = cache
.get(&[(vec!["ns".to_string()], "missing".to_string())])
.unwrap();
assert!(result.is_empty());
}

#[test]
fn test_cache_clear() {
let cache = InMemoryCache::new();
let ns = vec!["test".to_string()];
cache.set(&[((ns.clone(), "k1".to_string()), serde_json::json!(1), None)]).unwrap();
cache
.set(&[((ns.clone(), "k1".to_string()), serde_json::json!(1), None)])
.unwrap();
cache.clear(Some(&[ns.clone()])).unwrap();
let result = cache.get(&[(ns, "k1".to_string())]).unwrap();
assert!(result.is_empty());
Expand Down
Loading
Loading