Repository navigation
feat(core): add checkpoint namespaces - #129
kaihao-zhao wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
4 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.
| */ | ||
| public static class Builder extends HasMetadata.Builder<Builder> { | ||
| private String threadId; | ||
| private String checkpointNamespace; |
There was a problem hiding this comment.
The new checkpointNamespace field is not copied by the copy constructor Builder(RunnableConfig config) (RunnableConfig.java:285-292), which copies threadId, checkPointId, nextNode, streamMode, and metadata but omits checkpointNamespace.
This silently breaks tenant isolation in real execution flows:
AbstractCheckpointSaver.put()(AbstractCheckpointSaver.java:88) returnsRunnableConfig.builder(config).checkPointId(...).build()— the returned config loses its namespace.CompiledGraph.updateState(CompiledGraph.java:267-277) builds the result config from that put-returned config, so after the first checkpoint write the config falls back to the default namespace and subsequentget/put/releaseoperations read/write the wrong partition.withCheckPointId,withStreamMode, andwithMetadatahave the same data-loss problem.
The new tests pass only because they reuse the original configs and never the ones returned by put/with*.
Fix — add the missing copy in the copy constructor:
Builder( RunnableConfig config ) {
super( requireNonNull(config, "config cannot be null!").metadata );
this.threadId = config.threadId;
this.checkpointNamespace = config.checkpointNamespace;
this.checkPointId = config.checkPointId;
this.nextNode = config.nextNode;
this.streamMode = config.streamMode;
}Also add a regression test that asserts the namespace survives saver.put(...) and withCheckPointId(...).
| if (Objects.equals(this.threadId, checkpointNamespace)) { | ||
| return this; | ||
| } |
There was a problem hiding this comment.
The short-circuit guard compares the new namespace against this.threadId instead of this.checkpointNamespace. If the thread ID happens to equal the requested namespace (e.g. threadId("tenant-a") then withCheckpointNamespace("tenant-a")), the method returns this and the namespace is never set. Conversely, when the namespace is unchanged but the thread ID differs, a needless copy is created.
| if (Objects.equals(this.threadId, checkpointNamespace)) { | |
| return this; | |
| } | |
| if (Objects.equals(this.checkpointNamespace, checkpointNamespace)) { | |
| return this; | |
| } |
| private Path getNamespaceFolder(RunnableConfig config) { | ||
| return config.checkpointNamespace() | ||
| .map(targetFolder::resolve) | ||
| .orElse(targetFolder); | ||
| } |
There was a problem hiding this comment.
getNamespaceFolder resolves the raw namespace string under targetFolder with no validation. A namespace such as ../.. escapes the checkpoint root, and an absolute namespace (e.g. /etc or C:\) makes Path.resolve return that path outright, so checkpoints are read/written/deleted outside the intended folder. Since deleteFile, put, and release (backup copy) all flow through this path, a caller-controlled namespace can overwrite or delete arbitrary files.
The documentation added in this PR (src/site/mkdocs/core/core-library.md, "Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores") promises validation, but no validation exists anywhere in the code.
Fix: validate the namespace before use — e.g. in RunnableConfig.Builder.checkpointNamespace() or in getNamespaceFolder:
private static final Pattern SAFE_NAMESPACE = Pattern.compile("[A-Za-z0-9_-]+");
private Path getNamespaceFolder(RunnableConfig config) {
return config.checkpointNamespace()
.map(String::trim)
.filter(ns -> SAFE_NAMESPACE.matcher(ns).matches())
.map(targetFolder::resolve)
.orElse(targetFolder);
}(Throwing on invalid input is preferable to silently falling back to the default namespace, which would silently break isolation.)
| /** | ||
| * 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 returns an empty Optional — the $default fallback only exists in BaseCheckpointSaver.checkpointNamespace(RunnableConfig). This can mislead callers into expecting a materialized default here.
| * @return the configured namespace, or {@code $default} when no namespace is set | |
| * @return the configured namespace, or an empty {@code Optional} when no namespace is set |
Risk Assessment
|
There was a problem hiding this comment.
15 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 @unblocked-local-kaihao 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.
| */ | ||
| private RunnableConfig( Builder builder ) { | ||
| this.threadId = builder.threadId; | ||
| this.checkpointNamespace = builder.checkpointNamespace; |
There was a problem hiding this comment.
What changed — The config now takes its namespace from the builder.
Where it breaks — RunnableConfig.Builder(RunnableConfig) at RunnableConfig.java:281-290 does not copy that field. Graph execution copies its input config at CompiledGraph.java:674, then writes checkpoints using the copy at CompiledGraph.java:837.
Impact — A graph invoked in a tenant namespace reads from that namespace but writes to the default namespace; its state cannot be retrieved under the tenant config. Copy config.checkpointNamespace in the builder constructor.
| * @return a configuration using the requested namespace | ||
| */ | ||
| public RunnableConfig withCheckpointNamespace(String checkpointNamespace) { | ||
| if (Objects.equals(this.threadId, checkpointNamespace)) { |
There was a problem hiding this comment.
If the requested namespace equals the thread ID, this returns the unchanged config even when its namespace differs. Compare against this.checkpointNamespace instead.
| if (Objects.equals(this.threadId, checkpointNamespace)) { | |
| if (Objects.equals(this.checkpointNamespace, checkpointNamespace)) { |
| /** | ||
| * 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 method returns Optional.empty() when no namespace is set, not an Optional containing $default. The fallback occurs in BaseCheckpointSaver.checkpointNamespace(config); document this accessor’s actual return value.
| * @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.
Both (namespace="a:b", threadId="c") and (namespace="a", threadId="b:c") produce a:b:c; neither setter rejects colons. MemorySaver uses this key for reads, writes, and release, so those distinct tenants share checkpoints. Encode the two components unambiguously.
| default String checkpointNamespace(RunnableConfig config) { | ||
| return config.checkpointNamespace().orElse(CHECKPOINT_NAMESPACE_DEFAULT); |
There was a problem hiding this comment.
What changed — RunnableConfig now accepts a namespace and this interface exposes it, but does not require savers to use it.
Where it breaks — langgraph4j-hazelcast-saver/src/main/java/org/bsc/langgraph4j/checkpoint/HazelcastSaver.java:133-148 still loads, writes, and releases by threadId(config) alone; the other integration saver modules likewise do not use the new namespace.
Impact — With one of those savers, tenants using the same thread ID still access the same checkpoints despite supplying different namespaces. Apply namespace isolation in those savers, or reject namespaced configs where it is unsupported.
| * | ||
| * <p> | ||
| * Each RunnableConfig is associated with a file in the provided targetFolder. | ||
| * Each RunnableConfig is associated with a file in a namespace folder under the provided targetFolder. |
There was a problem hiding this comment.
“Each RunnableConfig” is inaccurate: getNamespaceFolder returns targetFolder itself when the namespace is absent, preserving the old layout. Say that only explicitly namespaced configs use a subfolder.
| | 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.
RunnableConfig.Builder.checkpointNamespace stores the string unchanged. For example, " acme " is neither trimmed nor rejected and selects a different key from "acme"; disallowed characters are accepted too. Normalize and validate as documented, or correct the documented contract.
| 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.
MemorySaver has no delete operation: it supports release, while deleteFile exists only on FileSystemSaver. Distinguish those APIs rather than saying both savers apply the namespace to delete operations.
| requireNonNull(outFile, "outFile cannot be null"); | ||
|
|
||
| final var outFilePath = outFile.toPath(); | ||
| Files.createDirectories(outFilePath.getParent()); |
There was a problem hiding this comment.
What changed — Serialization now creates every parent directory of the checkpoint path.
Where it breaks — FileSystemSaver.java:67-78 puts the unvalidated thread ID into that path. A thread ID such as x/../../other-store/thread-victim makes createDirectories create the intermediate thread-x directory, after which put can write other-store/thread-victim-saver.json outside the configured folder; previously that missing intermediate directory prevented the write.
Fix — Reject or safely encode path separators and traversal in thread IDs before constructing the filename, and check the final path against targetFolder.
| * @return the configured namespace or the default namespace | ||
| */ | ||
| default String checkpointNamespace(RunnableConfig config) { | ||
| return config.checkpointNamespace().orElse(CHECKPOINT_NAMESPACE_DEFAULT); |
There was a problem hiding this comment.
The builder accepts the literal namespace $default, which this method also assigns to configurations with no namespace. In MemorySaver, a caller supplying $default and another tenant's thread ID therefore reads or releases that tenant's legacy, unnamespaced checkpoint. Reject the reserved value or represent the absent namespace with a distinct internal key.
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.