Repository navigation
feat(core): add checkpoint namespaces - #133
kaihao-zhao wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
3 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.
Builder copy constructor does not copy checkpointNamespace
The Builder(RunnableConfig config) copy constructor copies threadId, checkPointId, nextNode, and streamMode, but omits the new checkpointNamespace field. This means every with*() method that creates a modified copy via RunnableConfig.builder(this) (e.g. withCheckPointId, withStreamMode, updateMetadata) will silently drop the checkpoint namespace from the resulting config.
Add the missing assignment:
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;
}| * @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.
This compares this.threadId against the new checkpointNamespace instead of this.checkpointNamespace. Copied from withCheckPointId (line 152) but the field name was not updated.
Consequences:
- If
threadIdhappens to equal the namespace string, the method silently returnsthiswithout applying the change. - If the namespace is already the desired value (but differs from
threadId), an unnecessary copy is created.
| if (Objects.equals(this.threadId, checkpointNamespace)) { | |
| if (Objects.equals(this.checkpointNamespace, checkpointNamespace)) { |
| private Path getNamespaceFolder(RunnableConfig config) { | ||
| return config.checkpointNamespace() | ||
| .map(targetFolder::resolve) | ||
| .orElse(targetFolder); | ||
| } |
There was a problem hiding this comment.
The namespace string from RunnableConfig is passed directly to targetFolder::resolve with no sanitization. A namespace like ../../other would escape the target folder. The documentation (core-library.md line R145) states "must contain only letters, digits, hyphens, or underscores" but this constraint is never enforced in the code.
Consider validating the namespace at the builder or at this call site, e.g.:
private Path getNamespaceFolder(RunnableConfig config) {
return config.checkpointNamespace()
.map(ns -> {
if (!ns.matches("[A-Za-z0-9_-]+")) {
throw new IllegalArgumentException(
"Invalid checkpoint namespace: " + ns);
}
return targetFolder.resolve(ns);
})
.orElse(targetFolder);
}
Risk AssessmentNo database schema files are changed, so neither schema policy matches. Namespace values are not validated before |
There was a problem hiding this comment.
3 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 was added to Builder, but the copy constructor Builder(RunnableConfig config) (RunnableConfig.java:285-292) was not updated to propagate it — it copies threadId, checkPointId, nextNode, and streamMode only. As a result, any config round-tripped through the builder silently loses its namespace.
This breaks the feature in the main execution path: AbstractCheckpointSaver.put() returns RunnableConfig.builder(config).checkPointId(checkpoint.getId()).build(), and CompiledGraph.updateState() hands that returned config back to the caller. After the first checkpoint save, the config's namespace is null, so all subsequent get/put/release calls fall back to the default namespace — tenant isolation is lost and cross-tenant state leakage becomes possible. The same silent drop affects withCheckPointId, withStreamMode, updateMetadata, and removeMetadata.
Fix: add the field to 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;
}| * @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.
The short-circuit check 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") and withCheckpointNamespace("tenant-a")), the method returns this and the namespace is never set — the caller believes it switched namespaces but checkpoints keep going to the previous one.
| if (Objects.equals(this.threadId, checkpointNamespace)) { | |
| if (Objects.equals(this.checkpointNamespace, checkpointNamespace)) { |
| 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 against targetFolder with no validation. A namespace such as "../..", "../../etc", or any absolute path escapes the checkpoint root entirely, allowing checkpoint files to be written to (or read/deleted from) arbitrary locations via serialize, deserialize, releaseCheckpoints, and deleteFile.
The documentation added in this PR (src/site/mkdocs/core/core-library.md, checkpointNamespace row) states that "Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores", but no trimming or validation is implemented anywhere — the documented contract is unenforced.
Fix: validate the namespace before use, e.g. in RunnableConfig.Builder.checkpointNamespace (trim, then reject anything not matching [A-Za-z0-9_-]+), or defensively in getNamespaceFolder. Restricting the character set also prevents ambiguous checkpointKey collisions in MemorySaver (namespace "a:b" + threadId "c" collides with namespace "a" + threadId "b:c").
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.