feat: add middelwares to the UI#202
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for registering and resolving generation middleware from the Genkit Dev UI. It adds a new use field to GenerateActionOptions to receive selected middleware names, resolves these names to fresh middleware instances in GenerateAction, and executes them through the wrapGenerate, wrapModel, and wrapTool chains. It also updates the registry to support typed value buckets (e.g., "middleware"), exposes these values via the reflection API endpoints (/api/values), and provides builder methods to pre-register middleware. The reviewer feedback highlights three key issues: a concurrency bug in Genkit.registerMiddlewareForDevUi due to a non-atomic check-then-act pattern, a thread-safety issue in DefaultRegistry.registerValue that can be resolved using putIfAbsent, and a potential deserialization issue in GenerateAction when converting tool inputs, suggesting the use of JsonUtils.convert(...) instead of the local objectMapper.
| private void registerMiddlewareForDevUi(List<GenerationMiddleware> use) { | ||
| if (use == null || use.isEmpty()) { | ||
| return; | ||
| } | ||
| for (GenerationMiddleware mw : use) { | ||
| if (mw == null) continue; | ||
| String name = mw.name(); | ||
| if (name == null || name.isEmpty()) continue; | ||
| if (registry.lookupValue("middleware", name) == null) { | ||
| registry.registerValue("middleware", name, mw); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
There is a critical concurrency bug here. The check-then-act pattern (lookupValue(...) == null followed by registerValue(...)) is not atomic. If multiple concurrent generate() calls are made using the same unregistered middleware, both threads can simultaneously pass the null check and attempt to register the middleware. This will cause one of the threads to throw an IllegalStateException from the registry, crashing that entire generation request.
To prevent this, we should wrap the registration in a try-catch block to safely ignore IllegalStateException if the middleware was already registered concurrently.
private void registerMiddlewareForDevUi(List<GenerationMiddleware> use) {
if (use == null || use.isEmpty()) {
return;
}
for (GenerationMiddleware mw : use) {
if (mw == null) continue;
String name = mw.name();
if (name == null || name.isEmpty()) continue;
if (registry.lookupValue("middleware", name) == null) {
try {
registry.registerValue("middleware", name, mw);
} catch (IllegalStateException e) {
// Safely ignore concurrent registration
}
}
}
}| public void registerValue(String type, String name, Object value) { | ||
| Map<String, Object> bucket = valuesByType.computeIfAbsent(type, t -> new ConcurrentHashMap<>()); | ||
| if (bucket.containsKey(name)) { | ||
| throw new IllegalStateException("Value already registered: " + type + "/" + name); | ||
| } | ||
| bucket.put(name, value); | ||
| logger.debug("Registered value: {}/{}", type, name); | ||
| } | ||
|
|
There was a problem hiding this comment.
The check-then-act pattern (containsKey followed by put) on the ConcurrentHashMap bucket is not atomic. In a highly concurrent environment, multiple threads registering the same value simultaneously could bypass the containsKey check and overwrite each other, or cause unexpected race conditions. Using putIfAbsent ensures atomicity and thread safety.
| public void registerValue(String type, String name, Object value) { | |
| Map<String, Object> bucket = valuesByType.computeIfAbsent(type, t -> new ConcurrentHashMap<>()); | |
| if (bucket.containsKey(name)) { | |
| throw new IllegalStateException("Value already registered: " + type + "/" + name); | |
| } | |
| bucket.put(name, value); | |
| logger.debug("Registered value: {}/{}", type, name); | |
| } | |
| @Override | |
| public void registerValue(String type, String name, Object value) { | |
| Map<String, Object> bucket = valuesByType.computeIfAbsent(type, t -> new ConcurrentHashMap<>()); | |
| if (bucket.putIfAbsent(name, value) != null) { | |
| throw new IllegalStateException("Value already registered: " + type + "/" + name); | |
| } | |
| logger.debug("Registered value: {}/{}", type, name); | |
| } |
| // Convert input if necessary | ||
| if (toolInput instanceof Map | ||
| && tool.getInputClass() != null | ||
| && !Map.class.isAssignableFrom(tool.getInputClass())) { | ||
| toolInput = objectMapper.convertValue(toolInput, tool.getInputClass()); | ||
| } |
There was a problem hiding this comment.
Using a local objectMapper directly to convert the tool input can lead to deserialization failures if the input contains custom types, custom date formats, or custom naming strategies registered globally. It is highly recommended to use JsonUtils.convert(...) instead, which is consistent with Genkit.java and uses the centrally configured object mapper.
// Convert input if necessary
Class<?> inputClass = tool.getInputClass();
if (inputClass != null && toolInput != null && !inputClass.isInstance(toolInput)) {
toolInput = JsonUtils.convert(toolInput, inputClass);
}
Description
Type of Change
Changes Made
Add middleware support to the Dev UI
Checklist
Screenshots (if applicable)
Documentation
Reviewer Notes