Skip to content

feat(core): add checkpoint namespaces - #129

Open
kaihao-zhao wants to merge 1 commit into
mainfrom
codex/checkpoint-namespaces-review-fixture-v57
Open

kaihao-zhao wants to merge 1 commit into
mainfrom
codex/checkpoint-namespaces-review-fixture-v57

Conversation

@kaihao-zhao

Copy link
Copy Markdown

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.

@dev-unblocked dev-unblocked Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) returns RunnableConfig.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 subsequent get/put/release operations read/write the wrong partition.
  • withCheckPointId, withStreamMode, and withMetadata have 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(...).

Comment on lines +168 to +170
if (Objects.equals(this.threadId, checkpointNamespace)) {
return this;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
if (Objects.equals(this.threadId, checkpointNamespace)) {
return this;
}
if (Objects.equals(this.checkpointNamespace, checkpointNamespace)) {
return this;
}

Comment on lines +71 to +75
private Path getNamespaceFolder(RunnableConfig config) {
return config.checkpointNamespace()
.map(targetFolder::resolve)
.orElse(targetFolder);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
* @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

@dev-unblocked

dev-unblocked Bot commented Sep 25, 2026

Copy link
Copy Markdown

Risk Assessment

Risk: medium

FileSystemSaver uses the namespace directly as a path component, but the documented trimming and character restrictions are not enforced; values containing path separators or traversal components can escape the intended namespace directory. Also, withCheckpointNamespace compares the new namespace to threadId rather than the existing namespace, so it can return the unchanged configuration for matching values.

@unblocked-local-kaihao unblocked-local-kaihao Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the requested namespace equals the thread ID, this returns the unchanged config even when its namespace differs. Compare against this.checkpointNamespace instead.

Suggested change
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +38 to +39
default String checkpointNamespace(RunnableConfig config) {
return config.checkpointNamespace().orElse(CHECKPOINT_NAMESPACE_DEFAULT);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“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. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@unblocked-local-kaihao

Copy link
Copy Markdown

Risk Assessment

Risk: high

FileSystemSaver resolves the caller-provided namespace directly as a path without validating it, so values containing path traversal or absolute paths can direct checkpoint reads, writes, and deletes outside the configured target folder. The documented character restrictions are not enforced in code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant