Skip to content

Commit bac6138

Browse files
community[minor]: vercel kv graph checkpointer (#5948)
* - feat: vercel kv graph checkpointer * - fix: downgraded uuid pkg version - fix: langchain.config.js - fix: moved to '/langgraph/checkpointers' * - fix: moved to '/langgraph/checkpointers' - fix: imports from '@langchain/langgraph/web' * fix: reverted uuid to ^9.0.0 * fix: reverted uuid version * - fix: langgraph version deps - fix: uuid version deps * fix: fixed uuid v6 in unit tests * fix: lock issues * Switch to integration test, format, lint * Update build artifacts * - fix: save checkpoint atomically in redis * - fix: nit unit test * - fix: types * - fix: non-blocking key lookup optimization --------- Co-authored-by: jacoblee93 <[email protected]>
1 parent c2d3472 commit bac6138

File tree

7 files changed

+314
-3
lines changed

7 files changed

+314
-3
lines changed

libs/langchain-community/.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -1042,6 +1042,10 @@ chains/graph_qa/cypher.cjs
10421042
chains/graph_qa/cypher.js
10431043
chains/graph_qa/cypher.d.ts
10441044
chains/graph_qa/cypher.d.cts
1045+
langgraph/checkpointers/vercel_kv.cjs
1046+
langgraph/checkpointers/vercel_kv.js
1047+
langgraph/checkpointers/vercel_kv.d.ts
1048+
langgraph/checkpointers/vercel_kv.d.cts
10451049
node_modules
10461050
dist
10471051
.yarn

libs/langchain-community/langchain.config.js

+6-2
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,9 @@ export const config = {
319319
"experimental/chat_models/ollama_functions": "experimental/chat_models/ollama_functions",
320320
"experimental/llms/chrome_ai": "experimental/llms/chrome_ai",
321321
// chains
322-
"chains/graph_qa/cypher": "chains/graph_qa/cypher"
322+
"chains/graph_qa/cypher": "chains/graph_qa/cypher",
323+
// langgraph checkpointers
324+
"langgraph/checkpointers/vercel_kv": "langgraph/checkpointers/vercel_kv"
323325
},
324326
requiresOptionalDependency: [
325327
"tools/aws_sfn",
@@ -517,7 +519,9 @@ export const config = {
517519
"experimental/multimodal_embeddings/googlevertexai",
518520
"experimental/hubs/makersuite/googlemakersuitehub",
519521
// chains
520-
"chains/graph_qa/cypher"
522+
"chains/graph_qa/cypher",
523+
// langgraph checkpointers
524+
"langgraph/checkpointers/vercel_kv"
521525
],
522526
packageSuffix: "community",
523527
tsConfigPath: resolve("./tsconfig.json"),

libs/langchain-community/package.json

+19-1
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"@gradientai/nodejs-sdk": "^1.2.0",
7979
"@huggingface/inference": "^2.6.4",
8080
"@jest/globals": "^29.5.0",
81+
"@langchain/langgraph": "~0.0.26",
8182
"@langchain/scripts": "~0.0.14",
8283
"@langchain/standard-tests": "0.0.0",
8384
"@layerup/layerup-security": "^1.5.12",
@@ -239,6 +240,7 @@
239240
"@google-cloud/storage": "^6.10.1 || ^7.7.0",
240241
"@gradientai/nodejs-sdk": "^1.2.0",
241242
"@huggingface/inference": "^2.6.4",
243+
"@langchain/langgraph": "~0.0.26",
242244
"@layerup/layerup-security": "^1.5.12",
243245
"@mendable/firecrawl-js": "^0.0.13",
244246
"@mlc-ai/web-llm": "0.2.46",
@@ -413,6 +415,9 @@
413415
"@huggingface/inference": {
414416
"optional": true
415417
},
418+
"@langchain/langgraph": {
419+
"optional": true
420+
},
416421
"@layerup/layerup-security": {
417422
"optional": true
418423
},
@@ -3049,6 +3054,15 @@
30493054
"import": "./chains/graph_qa/cypher.js",
30503055
"require": "./chains/graph_qa/cypher.cjs"
30513056
},
3057+
"./langgraph/checkpointers/vercel_kv": {
3058+
"types": {
3059+
"import": "./langgraph/checkpointers/vercel_kv.d.ts",
3060+
"require": "./langgraph/checkpointers/vercel_kv.d.cts",
3061+
"default": "./langgraph/checkpointers/vercel_kv.d.ts"
3062+
},
3063+
"import": "./langgraph/checkpointers/vercel_kv.js",
3064+
"require": "./langgraph/checkpointers/vercel_kv.cjs"
3065+
},
30523066
"./package.json": "./package.json"
30533067
},
30543068
"files": [
@@ -4096,6 +4110,10 @@
40964110
"chains/graph_qa/cypher.cjs",
40974111
"chains/graph_qa/cypher.js",
40984112
"chains/graph_qa/cypher.d.ts",
4099-
"chains/graph_qa/cypher.d.cts"
4113+
"chains/graph_qa/cypher.d.cts",
4114+
"langgraph/checkpointers/vercel_kv.cjs",
4115+
"langgraph/checkpointers/vercel_kv.js",
4116+
"langgraph/checkpointers/vercel_kv.d.ts",
4117+
"langgraph/checkpointers/vercel_kv.d.cts"
41004118
]
41014119
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/* eslint-disable no-process-env */
2+
3+
import { describe, test, expect } from "@jest/globals";
4+
import { Checkpoint, CheckpointTuple } from "@langchain/langgraph";
5+
import { VercelKVSaver } from "../vercel_kv.js";
6+
7+
const checkpoint1: Checkpoint = {
8+
v: 1,
9+
id: "1ef390c8-3ed9-6132-ffff-12d236274621",
10+
ts: "2024-04-19T17:19:07.952Z",
11+
channel_values: {
12+
someKey1: "someValue1",
13+
},
14+
channel_versions: {
15+
someKey2: 1,
16+
},
17+
versions_seen: {
18+
someKey3: {
19+
someKey4: 1,
20+
},
21+
},
22+
};
23+
24+
const checkpoint2: Checkpoint = {
25+
v: 1,
26+
id: "1ef390c8-3ed9-6133-8001-419c612dad04",
27+
ts: "2024-04-20T17:19:07.952Z",
28+
channel_values: {
29+
someKey1: "someValue2",
30+
},
31+
channel_versions: {
32+
someKey2: 2,
33+
},
34+
versions_seen: {
35+
someKey3: {
36+
someKey4: 2,
37+
},
38+
},
39+
};
40+
41+
describe("VercelKVSaver", () => {
42+
const vercelSaver = new VercelKVSaver({
43+
url: process.env.VERCEL_KV_API_URL!,
44+
token: process.env.VERCEL_KV_API_TOKEN!,
45+
});
46+
47+
test("should save and retrieve checkpoints correctly", async () => {
48+
// save checkpoint
49+
const runnableConfig = await vercelSaver.put(
50+
{ configurable: { thread_id: "1" } },
51+
checkpoint1,
52+
{ source: "update", step: -1, writes: null }
53+
);
54+
expect(runnableConfig).toEqual({
55+
configurable: {
56+
thread_id: "1",
57+
checkpoint_id: checkpoint1.id,
58+
},
59+
});
60+
61+
// get checkpoint tuple
62+
const checkpointTuple = await vercelSaver.getTuple({
63+
configurable: { thread_id: "1" },
64+
});
65+
expect(checkpointTuple?.config).toEqual({
66+
configurable: {
67+
thread_id: "1",
68+
checkpoint_id: checkpoint1.id,
69+
},
70+
});
71+
expect(checkpointTuple?.checkpoint).toEqual(checkpoint1);
72+
73+
// save another checkpoint
74+
await vercelSaver.put(
75+
{
76+
configurable: {
77+
thread_id: "1",
78+
},
79+
},
80+
checkpoint2,
81+
{ source: "update", step: -1, writes: null }
82+
);
83+
// list checkpoints
84+
const checkpointTupleGenerator = vercelSaver.list({
85+
configurable: { thread_id: "1" },
86+
});
87+
88+
const checkpointTuples: CheckpointTuple[] = [];
89+
90+
for await (const checkpoint of checkpointTupleGenerator) {
91+
checkpointTuples.push(checkpoint);
92+
}
93+
expect(checkpointTuples.length).toBe(2);
94+
95+
const checkpointTuple1 = checkpointTuples[0];
96+
const checkpointTuple2 = checkpointTuples[1];
97+
98+
expect(checkpointTuple1.checkpoint.ts).toBe("2024-04-20T17:19:07.952Z");
99+
expect(checkpointTuple2.checkpoint.ts).toBe("2024-04-19T17:19:07.952Z");
100+
});
101+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { VercelKV, createClient } from "@vercel/kv";
2+
3+
import { RunnableConfig } from "@langchain/core/runnables";
4+
import {
5+
BaseCheckpointSaver,
6+
Checkpoint,
7+
CheckpointMetadata,
8+
CheckpointTuple,
9+
SerializerProtocol,
10+
} from "@langchain/langgraph/web";
11+
12+
// snake_case is used to match Python implementation
13+
interface KVRow {
14+
checkpoint: string;
15+
metadata: string;
16+
}
17+
18+
interface KVConfig {
19+
url: string;
20+
token: string;
21+
}
22+
23+
export class VercelKVSaver extends BaseCheckpointSaver {
24+
private kv: VercelKV;
25+
26+
constructor(config: KVConfig, serde?: SerializerProtocol<unknown>) {
27+
super(serde);
28+
this.kv = createClient(config);
29+
}
30+
31+
async getTuple(config: RunnableConfig): Promise<CheckpointTuple | undefined> {
32+
const thread_id = config.configurable?.thread_id;
33+
const checkpoint_id = config.configurable?.checkpoint_id;
34+
35+
if (!thread_id) {
36+
return undefined;
37+
}
38+
39+
const key = checkpoint_id
40+
? `${thread_id}:${checkpoint_id}`
41+
: `${thread_id}:last`;
42+
43+
const row: KVRow | null = await this.kv.get(key);
44+
45+
if (!row) {
46+
return undefined;
47+
}
48+
49+
const [checkpoint, metadata] = await Promise.all([
50+
this.serde.parse(row.checkpoint),
51+
this.serde.parse(row.metadata),
52+
]);
53+
54+
return {
55+
checkpoint: checkpoint as Checkpoint,
56+
metadata: metadata as CheckpointMetadata,
57+
config: checkpoint_id
58+
? config
59+
: {
60+
configurable: {
61+
thread_id,
62+
checkpoint_id: (checkpoint as Checkpoint).id,
63+
},
64+
},
65+
};
66+
}
67+
68+
async *list(
69+
config: RunnableConfig,
70+
limit?: number,
71+
before?: RunnableConfig
72+
): AsyncGenerator<CheckpointTuple> {
73+
const thread_id: string = config.configurable?.thread_id;
74+
75+
// LUA script to get keys excluding those starting with "last"
76+
const luaScript = `
77+
local prefix = ARGV[1]
78+
local cursor = '0'
79+
local result = {}
80+
repeat
81+
local scanResult = redis.call('SCAN', cursor, 'MATCH', prefix .. '*', 'COUNT', 1000)
82+
cursor = scanResult[1]
83+
local keys = scanResult[2]
84+
for _, key in ipairs(keys) do
85+
if key:sub(-5) ~= ':last' then
86+
table.insert(result, key)
87+
end
88+
end
89+
until cursor == '0'
90+
return result
91+
`;
92+
93+
// Execute the LUA script with the thread_id as an argument
94+
const keys: string[] = await this.kv.eval(luaScript, [], [thread_id]);
95+
96+
const filteredKeys = keys.filter((key: string) => {
97+
const [, checkpoint_id] = key.split(":");
98+
99+
return !before || checkpoint_id < before?.configurable?.checkpoint_id;
100+
});
101+
102+
const sortedKeys = filteredKeys
103+
.sort((a: string, b: string) => b.localeCompare(a))
104+
.slice(0, limit);
105+
106+
const rows: (KVRow | null)[] = await this.kv.mget(...sortedKeys);
107+
for (const row of rows) {
108+
if (row) {
109+
const [checkpoint, metadata] = await Promise.all([
110+
this.serde.parse(row.checkpoint),
111+
this.serde.parse(row.metadata),
112+
]);
113+
114+
yield {
115+
config: {
116+
configurable: {
117+
thread_id,
118+
checkpoint_id: (checkpoint as Checkpoint).id,
119+
},
120+
},
121+
checkpoint: checkpoint as Checkpoint,
122+
metadata: metadata as CheckpointMetadata,
123+
};
124+
}
125+
}
126+
}
127+
128+
async put(
129+
config: RunnableConfig,
130+
checkpoint: Checkpoint,
131+
metadata: CheckpointMetadata
132+
): Promise<RunnableConfig> {
133+
const thread_id = config.configurable?.thread_id;
134+
135+
if (!thread_id || !checkpoint.id) {
136+
throw new Error("Thread ID and Checkpoint ID must be defined");
137+
}
138+
139+
const row: KVRow = {
140+
checkpoint: this.serde.stringify(checkpoint),
141+
metadata: this.serde.stringify(metadata),
142+
};
143+
144+
// LUA script to set checkpoint data atomically"
145+
const luaScript = `
146+
local thread_id = ARGV[1]
147+
local checkpoint_id = ARGV[2]
148+
local row = ARGV[3]
149+
150+
redis.call('SET', thread_id .. ':' .. checkpoint_id, row)
151+
redis.call('SET', thread_id .. ':last', row)
152+
`;
153+
154+
// Save the checkpoint and the last checkpoint
155+
await this.kv.eval(luaScript, [], [thread_id, checkpoint.id, row]);
156+
157+
return {
158+
configurable: {
159+
thread_id,
160+
checkpoint_id: checkpoint.id,
161+
},
162+
};
163+
}
164+
}

libs/langchain-community/src/load/import_constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -183,4 +183,5 @@ export const optionalImportEntrypoints: string[] = [
183183
"langchain_community/experimental/multimodal_embeddings/googlevertexai",
184184
"langchain_community/experimental/hubs/makersuite/googlemakersuitehub",
185185
"langchain_community/chains/graph_qa/cypher",
186+
"langchain_community/langgraph/checkpointers/vercel_kv",
186187
];

0 commit comments

Comments
 (0)