Repository navigation
feat(core): add checkpoint namespaces - #136
kaihao-zhao wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
6 issues found.
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @dev-unblocked to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
| if (Objects.equals(this.threadId, checkpointNamespace)) { | ||
| return this; | ||
| } |
There was a problem hiding this comment.
The short-circuit guard checks the wrong field: it compares the new namespace against this.threadId instead of this.checkpointNamespace. When a config's threadId happens to equal the requested namespace (e.g. threadId("tenant-acme") then withCheckpointNamespace("tenant-acme")), the method returns this and the namespace is silently never set — checkpoints stay in the default namespace. Conversely, when the namespace really is unchanged, a needless copy is built.
| if (Objects.equals(this.threadId, checkpointNamespace)) { | |
| return this; | |
| } | |
| if (Objects.equals(this.checkpointNamespace, checkpointNamespace)) { | |
| return this; | |
| } |
| /** | ||
| * Returns the namespace used to partition checkpoints. | ||
| * | ||
| * @return the configured namespace, or {@code $default} when no namespace is set |
There was a problem hiding this comment.
The javadoc says the method returns "$default when no namespace is set", but the implementation at line 108 returns ofNullable(checkpointNamespace) — an empty Optional when unset. The $default fallback only exists in BaseCheckpointSaver.checkpointNamespace(). Fix the doc to say it returns an empty Optional when no namespace is configured.
| | Attribute | Type | Description | | ||
| | --------- | ---- | ----------- | | ||
| | **threadId** | `String` | A unique identifier for the execution thread/session. Essential for checkpoint-based persistence, as it groups related executions together. Allows resuming interrupted graphs or maintaining conversation history. | | ||
| | **checkpointNamespace** | `String` | Optional partition for checkpoint data. Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores. | |
There was a problem hiding this comment.
The docs state "Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores", but neither Builder.checkpointNamespace() nor withCheckpointNamespace() performs any trimming or validation — the raw string is stored as-is. Either implement the documented validation (which would also close the path-traversal hole in FileSystemSaver.getNamespaceFolder) or remove the claim from the docs.
| private Path getNamespaceFolder(RunnableConfig config) { | ||
| return config.checkpointNamespace() | ||
| .map(targetFolder::resolve) | ||
| .orElse(targetFolder); | ||
| } |
There was a problem hiding this comment.
What changed — getNamespaceFolder resolves the raw checkpointNamespace value directly under targetFolder.
Where it breaks — Path.resolve accepts .. segments, so a namespace like "../../tmp/evil" produces a folder outside the checkpoint store; serialize() (line 90) then calls Files.createDirectories on it and writes checkpoint files there. deleteFile and the release/backup path operate on the same escaped location.
Impact — since namespaces exist precisely to carry tenant-derived values, a tenant-supplied namespace can read/write checkpoint files at arbitrary filesystem locations relative to targetFolder.
Fix — validate the namespace before use (e.g. reject anything not matching [A-Za-z0-9_-]+, as the documentation already promises), or at minimum reject names containing /, \, or .. in getNamespaceFolder.
| * @return namespace-qualified thread key | ||
| */ | ||
| default String checkpointKey(RunnableConfig config) { | ||
| return "%s:%s".formatted(checkpointNamespace(config), threadId(config)); |
There was a problem hiding this comment.
checkpointKey joins namespace and threadId with a plain : and no escaping, so namespace "a" + threadId "b:c" produces the same key as namespace "a:b" + threadId "c". In MemorySaver both configs then share one checkpoint list — a cross-namespace state leak that defeats the tenant isolation this feature exists to provide. Use a delimiter that cannot appear in either value, or escape : in the components (e.g. percent-encode) before joining.
| graph.invoke(inputs, config); | ||
| ``` | ||
|
|
||
| Checkpoint namespaces allow separate applications or tenants to reuse the same thread ID without sharing state. When omitted, checkpoints continue to use the default namespace for backward compatibility. `MemorySaver` and `FileSystemSaver` both apply the namespace to list, get, put, release, and delete operations. |
There was a problem hiding this comment.
What changed — the docs present checkpointNamespace as a working isolation mechanism and name only MemorySaver and FileSystemSaver as applying it.
Where it breaks — VersionedMemorySaver (same core module) keys its release history by threadId only (VersionedMemorySaver.java:175, _checkpointsHistoryByThread), so two tenants sharing a threadId get a merged version history and versionsByThreadId/getCheckpointsByVersion return another tenant's checkpoints. Likewise RedisSaver, PostgresSaver, MysqlSaver, OracleSaver, CockroachDBSaver, DynamoDBSaver, and HazelcastSaver never consult checkpointNamespace, so setting it provides no isolation and no error.
Impact — a user who sets checkpointNamespace with any saver other than the two named ones gets cross-tenant state sharing with no warning, while believing they are isolated.
Fix — either apply checkpointKey in the remaining savers (starting with VersionedMemorySaver's history map) or document explicitly that the namespace is currently honored only by MemorySaver and FileSystemSaver.
Also at:
langgraph4j-core/src/main/java/org/bsc/langgraph4j/checkpoint/BaseCheckpointSaver.java:38
Risk Assessment
|
Adds checkpoint namespace support to RunnableConfig and applies namespace isolation to the built-in memory and filesystem checkpoint savers. Includes API documentation, backward-compatible default behavior, and focused tests for tenant isolation. Test command: ./mvnw -pl langgraph4j-core test.