Skip to content

feat(core): add checkpoint namespaces - #131

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

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

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.

* @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.

The short-circuit check compares this.threadId with the new checkpointNamespace. This is a copy-paste error: when the thread ID happens to equal the requested namespace, the method returns this and silently ignores the namespace change; conversely, when the namespace is unchanged but the thread ID differs, a needless copy is created. Compare against this.checkpointNamespace as done for checkPointId in withCheckPointId.

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

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 against targetFolder with no validation. A namespace such as ../other (or an absolute path, which Path.resolve adopts wholesale) makes getPath, serialize, releaseCheckpoints, and deleteFile read/write/delete files outside the checkpoint root. Since namespaces are intended for tenant IDs — plausibly derived from external input — this is a path-traversal risk. It also contradicts the new documentation in src/site/mkdocs/core/core-library.md, which states namespaces "are trimmed and must contain only letters, digits, hyphens, or underscores" — no such trimming or validation exists anywhere in the code. Enforce the documented contract, e.g.:

private static final Pattern NAMESPACE_PATTERN = Pattern.compile("[A-Za-z0-9_-]+");

private Path getNamespaceFolder(RunnableConfig config) {
    return config.checkpointNamespace()
            .map(String::trim)
            .filter(ns -> NAMESPACE_PATTERN.matcher(ns).matches())
            .map(targetFolder::resolve)
            .orElse(targetFolder);
}

(or reject invalid namespaces with an exception in the RunnableConfig.Builder so the guarantee holds for all savers).

Comment on lines +24 to +25
final var checkpointKey = checkpointKey(config);
return new Tag( threadId(config), _checkpointsByThread.remove( checkpointKey ) );

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 now keys checkpoints by checkpointKey (namespace:threadId), and VersionedMemorySaver delegates put/get/list to it — so live checkpoints are isolated. However, VersionedMemorySaver.release stores the returned Tag in _checkpointsHistoryByThread keyed by bare threadId (see VersionedMemorySaver.java lines 175–184), and versionsByThreadId/lastVersionByThreadId/getCheckpointsByVersion are threadId-only. Two tenants sharing a thread ID therefore cross-contaminate each other's released-checkpoint history, silently breaking the tenant isolation this PR promises for the built-in memory savers. Either key the history by checkpointKey(config) too, or document that VersionedMemorySaver does not support namespaces.

/**
* 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 ofNullable(checkpointNamespace) — i.e., an empty Optional. The $default fallback only exists in BaseCheckpointSaver.checkpointNamespace(RunnableConfig). Correct the doc to match the actual contract.

Suggested change
* @return the configured namespace, or {@code $default} when no namespace is set
* @return an {@link Optional} containing the configured namespace, or empty if none is set

@dev-unblocked

dev-unblocked Bot commented Sep 27, 2026

Copy link
Copy Markdown

Risk Assessment

Risk: medium

The new filesystem saver resolves the caller-supplied namespace directly as a path, but the documented trimming and character restrictions are not enforced, so namespace values can escape the target folder. Also, withCheckpointNamespace compares the requested namespace to threadId instead of the current namespace, causing incorrect no-op behavior in some cases.

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

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

*/
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.

What changed — The builder has a namespace field, but Builder(RunnableConfig config) never copies config.checkpointNamespace into it.

Where it breaks — CompiledGraph.java:674 copies the supplied config before execution, and AbstractCheckpointSaver.java:88 copies it when returning from put. Both copies lose the namespace.

Impact — A graph can read a tenant's checkpoint and then write subsequent checkpoints to the default namespace. Copy the namespace in the builder's copy constructor.


private Path getNamespaceFolder(RunnableConfig config) {
return config.checkpointNamespace()
.map(targetFolder::resolve)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

targetFolder.resolve accepts namespace strings such as ../../other or an absolute path; Java's path resolution does not confine either to targetFolder. A caller-controlled namespace can therefore direct checkpoint reads, writes, releases, and deletion outside the configured store. Validate namespaces as single directory names and enforce containment before file operations.

* @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.

The early return compares checkpointNamespace with threadId. If they match, the requested namespace is never set; this also prevents clearing a namespace when the thread ID is absent.

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

* @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.

MemorySaver.java:32 indexes its shared map with this key, but neither component is escaped. Namespace a:b with thread c and namespace a with thread b:c both produce a:b:c, so the two configurations share checkpoints. Use an injective encoding or a composite key.

final var threadId = threadId(config);
return new Tag( threadId(config), _checkpointsByThread.remove( threadId ) );
final var checkpointKey = checkpointKey(config);
return new Tag( threadId(config), _checkpointsByThread.remove( checkpointKey ) );

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 — Release removes a namespace-specific checkpoint list, but the returned tag still identifies it only by thread ID.

Where it breaks — VersionedMemorySaver.java:175-180 delegates to this release and stores tags in its history map under threadId alone. Its config-based version queries likewise discard the namespace.

Impact — Releasing the same thread ID in two namespaces combines their version histories. Include the namespace in version-history indexing and config-based lookups.

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

This says namespaces are trimmed and restricted to letters, digits, hyphens, and underscores, but RunnableConfig.Builder.checkpointNamespace stores the string unchanged. For example, " tenant-a " remains distinct from "tenant-a", and strings containing : are accepted. Implement the documented normalization and validation, or correct the contract.

/**
* 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 the field is null, not an Optional containing $default. The fallback occurs in BaseCheckpointSaver; update this return documentation to describe the getter's actual result.

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.

The sentence says both savers apply the namespace to deletion, but MemorySaver and its base interfaces have no delete operation. Only FileSystemSaver provides deleteFile; distinguish its operation from the methods both savers support.

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

This says each configuration's file is in a namespace folder, but getNamespaceFolder returns targetFolder itself when no namespace is set. State that only explicitly namespaced configurations use subfolders; legacy files remain at the root.

@Override
protected Tag releaseCheckpoints(RunnableConfig config, LinkedList<Checkpoint> checkpoints) throws Exception {
final var currentPath = getPath(config);
final var namesapceFolder = getNamespaceFolder(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.

namesapceFolder misspells “namespace” and differs from getNamespaceFolder. Rename it consistently at its declaration and three uses.

@unblocked-local-kaihao

Copy link
Copy Markdown

Risk Assessment

Risk: high

FileSystemSaver resolves the configured namespace directly as a filesystem path, but the implementation does not enforce the documented character restrictions, so values such as ../... or absolute paths can escape the target folder and undermine namespace isolation. withCheckpointNamespace also compares the requested namespace to threadId rather than the existing namespace, causing it to silently ignore a namespace equal to the thread ID.

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