Skip to content

feat(core): add checkpoint namespaces - #149

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

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

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.

7 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 was added to Builder, but the copy constructor Builder(RunnableConfig config) (~line 288) was not updated — it copies only threadId, checkPointId, nextNode, and streamMode. As a result, every config derived through RunnableConfig.builder(config) silently loses the namespace. This includes withCheckPointId, withStreamMode, updateMetadata, StateSnapshot.of (StateSnapshot.java:37), AbstractCheckpointSaver.put (AbstractCheckpointSaver.java:88), and critically CompiledGraph.AsyncNodeGenerator (CompiledGraph.java:674), which rebuilds the config at the start of every invoke/stream call. So a config built with .checkpointNamespace("tenant-a") loses its namespace the moment a graph run starts, and all checkpoints are written to the default namespace — tenant isolation is non-functional in real executions (the added tests only call the saver directly, which is why they pass).

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

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 wrong field: Objects.equals(this.threadId, checkpointNamespace) instead of this.checkpointNamespace. Two failure modes: (1) if the threadId happens to equal the requested namespace (e.g. threadId "tenant-globex", namespace null), the method returns this and the namespace is silently NOT set; (2) when the namespace is genuinely unchanged, a new config is still built (harmless but defeats the guard).

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 @return tag says "the configured namespace, or {@code $default} when no namespace is set", but the implementation returns ofNullable(checkpointNamespace) — i.e. Optional.empty() when unset; the $default substitution only happens later in BaseCheckpointSaver.checkpointNamespace. Correct the javadoc to match the actual contract, e.g. "an empty Optional when no namespace is set".

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 passes the raw namespace string to targetFolder::resolve with no sanitization. A namespace of "../other-tenant" (or any absolute path, which Path.resolve adopts wholesale) makes getPath/getFile point outside targetFolder, so one tenant can read, overwrite, or delete another tenant's checkpoint files (and releaseCheckpoints will even back them up into the foreign folder). Since the namespace is caller-supplied configuration, validate it before use — e.g. reject anything that is not [A-Za-z0-9_-]+ after trimming (which is exactly what the documentation already promises), or at minimum verify the resolved path starts with targetFolder.

* @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 two unvalidated strings with ":", so namespace "a" + threadId "b:c" and namespace "a:b" + threadId "c" both produce the key "a:b:c". In MemorySaver this means two different (namespace, thread) pairs share the same checkpoint list — cross-tenant state leakage. Use a separator that cannot appear in either component, or validate both components (the documented charset restriction would also fix this).

Comment on lines +38 to +40
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.

Namespace support was added to the BaseCheckpointSaver interface, but only MemorySaver and FileSystemSaver consume it. VersionedMemorySaver (a built-in core saver) keys _checkpointsHistoryByThread by config.threadId().orElse(THREAD_ID_DEFAULT) only (VersionedMemorySaver.java:180), so version history from different namespaces with the same threadId is merged. Likewise the module savers all key by threadId(config) and ignore the namespace: RedisSaver.java:347/522, PostgresSaver.java:262, AbstractMysqlServer.java:155/226, DynamoDBSaver.java:284+, plus the Oracle, Hazelcast, and CockroachDB savers. A caller who sets checkpointNamespace with any of these savers gets cross-tenant shared state with no warning, while the docs state namespaces let tenants reuse the same thread ID without sharing state. Either apply checkpointKey(config) in these savers or document explicitly that the namespace is only honored by MemorySaver/FileSystemSaver.

Also at:

  • langgraph4j-core/src/main/java/org/bsc/langgraph4j/checkpoint/MemorySaver.java:24

| 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 attribute table states "Namespace values are trimmed and must contain only letters, digits, hyphens, or underscores", but RunnableConfig.Builder.checkpointNamespace (RunnableConfig.java:314) stores the value verbatim and no saver trims or validates it. Either implement the documented validation (which would also close the path-traversal and key-collision issues) or remove the claim from the docs.

@dev-unblocked

dev-unblocked Bot commented Oct 1, 2026

Copy link
Copy Markdown

Risk Assessment

Risk: high

FileSystemSaver uses the namespace directly in targetFolder.resolve(...), but the builder does not enforce the documented character restrictions, allowing path-traversal values to escape the checkpoint directory and undermine isolation. Also, withCheckpointNamespace compares the requested namespace to threadId, which can silently prevent an update.

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

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

The new field is copied from the builder into the config here. However, the copy constructor Builder(RunnableConfig config) (around lines 286-292) was not updated. It copies threadId, checkPointId, nextNode and streamMode, but not checkpointNamespace. Every RunnableConfig.builder(config) / withXxx(...) / updateMetadata(...) call therefore silently resets the namespace to null.

This breaks the feature at runtime:

  • CompiledGraph.AsyncNodeGenerator starts with RunnableConfig.builder(config).checkPointId(null) (CompiledGraph.java:674). Every config used by addCheckpoint / saver.put during graph execution has no namespace, so all tenants' checkpoints go into the default namespace and share state again.
  • AbstractCheckpointSaver.put returns RunnableConfig.builder(config).checkPointId(...). A caller that reuses the returned config for get/updateState reads from the default namespace.
  • SubCompiledGraphNodeAction, updateState, withStreamMode, withCheckPointId and updateMetadata all lose the namespace in the same way.

The new tests only call saver.put/get directly with the original config, so they never hit this.

Fix: add this.checkpointNamespace = config.checkpointNamespace; to Builder(RunnableConfig config). Also add a test that runs a compiled graph with a namespaced config, or at least checks that builder(config).build().checkpointNamespace() keeps the value.

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 compares against the wrong field. When the requested namespace equals the thread id (e.g. thread "acme", namespace "acme"), the method returns this without changing the namespace. The caller's config then stays in its old (or default) namespace, which is a silent isolation failure. In the opposite case, an unchanged namespace still builds a new instance for no reason. The sibling withCheckPointId compares against its own field.

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

Comment on lines +105 to +108
* @return the configured namespace, or {@code $default} when no namespace is set
*/
public Optional<String> checkpointNamespace() {
return ofNullable(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 @return says "the configured namespace, or $default when no namespace is set". The method actually returns ofNullable(checkpointNamespace), which is Optional.empty() when no namespace is set. The $default fallback only exists in BaseCheckpointSaver.checkpointNamespace(config). FileSystemSaver.getNamespaceFolder depends on this empty value: it uses the root folder, not a $default subfolder. Update the docs to say an empty Optional is returned when unset.

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.

targetFolder.resolve(namespace) is called on the raw namespace string. A namespace like "../../other-tenant" or "../.." escapes the checkpoint root. An absolute value like "/etc" makes resolve return that absolute path outright. serialize then calls Files.createDirectories(outFilePath.getParent()) and writes a file there. releaseCheckpoints lists, copies and deletes files there, and deleteFile deletes there.

Namespaces are intended to be tenant identifiers, which may come from request data. That makes this an arbitrary directory create, file write and file delete, and a way to read another tenant's checkpoints (e.g. "../tenant-b").

The docs added in this PR say namespaces are "trimmed and must contain only letters, digits, hyphens, or underscores", but nothing enforces that. Validate in RunnableConfig.Builder.checkpointNamespace (or here) against ^[A-Za-z0-9_-]+$ after trimming. Also check that resolved.normalize().startsWith(targetFolder.normalize()).

| 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 say namespace values "are trimmed and must contain only letters, digits, hyphens, or underscores". RunnableConfig.Builder.checkpointNamespace just stores the raw string, and no saver validates or trims it. So " tenant-a" and "tenant-a" are different namespaces, and values with /, .. or : are accepted. Either add the validation (preferred, given the FileSystemSaver path issue) or correct the docs.

Comment on lines +48 to +50
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.

The key is namespace + ":" + threadId with no escaping, and neither part is restricted. So namespace "a:b" with thread "c" and namespace "a" with thread "b:c" both map to "a:b:c". In MemorySaver the two tenants then share one checkpoint list, which defeats the isolation this key is meant to provide. Use a collision-free key (e.g. a record key (namespace, threadId) or a nested map), or reject : in namespaces through the validation the docs describe.

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 separates live checkpoints by namespace. But VersionedMemorySaver, which delegates to an inner MemorySaver, still writes released tags into _checkpointsHistoryByThread keyed by config.threadId().orElse(THREAD_ID_DEFAULT) (VersionedMemorySaver.java:175). When two namespaces release the same thread id, their tags go into one shared version history. versionsByThreadId / getCheckpointsByVersion then return the other tenant's checkpoints. Key the history with checkpointKey(config) too, or document that versioning does not support namespaces.

@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 is misspelled. It is also used at lines 157, 166 and 172. Rename it to namespaceFolder.

@unblocked-local-kaihao

Copy link
Copy Markdown

Risk Assessment

Risk: high

The namespace is documented as restricted to trimmed alphanumeric, hyphen, and underscore values, but the builder does not enforce this; FileSystemSaver resolves it directly as a path, allowing values such as ../... to escape the checkpoint directory and undermine isolation. Also, withCheckpointNamespace compares the requested namespace to threadId rather than the current namespace, so some updates are silently ignored.

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