Skip to content

feat(core): add checkpoint namespaces - #136

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

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

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.

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.

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

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

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

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

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.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

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

@dev-unblocked

dev-unblocked Bot commented Sep 28, 2026

Copy link
Copy Markdown

Risk Assessment

Risk: high

FileSystemSaver resolves the caller-provided namespace directly under the checkpoint folder without validating it, so .. or an absolute namespace can direct checkpoint writes outside the configured folder; this conflicts with the documented namespace restrictions. withCheckpointNamespace also compares the new namespace to threadId rather than the existing namespace, causing incorrect no-op behavior in that case.

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