Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Builder;
import lombok.Data;
import lombok.RequiredArgsConstructor;
Expand Down Expand Up @@ -74,7 +75,9 @@ public abstract sealed class AutomationRuleEvaluator<T, E extends Filter> implem
private final Set<UUID> projectIds;

@JsonView({View.Public.class, View.Write.class})
@NotBlank private final String name;
// Bounded to match the automation_rules.name VARCHAR(150) column, so an over-long name is rejected at
// the API boundary instead of failing the insert (OPIK-7371).
@NotBlank @Size(max = 150, message = "cannot exceed 150 characters") private final String name;

Comment thread
awkoy marked this conversation as resolved.
@JsonView({View.Public.class, View.Write.class})
private final float samplingRate;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
Expand Down Expand Up @@ -49,7 +50,9 @@ public abstract sealed class AutomationRuleEvaluatorUpdate<T, E extends Filter>
AutomationRuleEvaluatorUpdateUserDefinedMetricPython,
AutomationRuleEvaluatorUpdateSpanLlmAsJudge, AutomationRuleEvaluatorUpdateSpanUserDefinedMetricPython {

@NotBlank private final String name;
// Bounded to match the automation_rules.name VARCHAR(150) column, so an over-long rename is rejected at
// the API boundary instead of failing the update (OPIK-7371).
@NotBlank @Size(max = 150, message = "cannot exceed 150 characters") private final String name;

Comment thread
awkoy marked this conversation as resolved.
private final float samplingRate;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import org.jdbi.v3.stringtemplate4.UseStringTemplateEngine;

import java.util.Optional;
import java.util.Set;
import java.util.UUID;

Expand Down Expand Up @@ -42,6 +43,57 @@ HAVING COUNT(project_id) > 1
"VALUES (:rule.id, :workspaceId, :rule.action, :rule.name, :rule.samplingRate, :rule.enabled, :rule.triggerScope, :rule.filters)")
void saveBaseRule(@BindMethods("rule") AutomationRuleModel rule, @Bind("workspaceId") String workspaceId);

/**
* Returns existing rule names in the given project(s) that start with {@code namePrefix}, used to
* auto-suffix colliding names (OPIK-7371). Scoped per project via the junction table (the authoritative
* association after the AutomationRuleProjectMigration backfill); the legacy {@code project_id} column
* is intentionally not used (it is nulled on update). {@code excludeRuleId} (optional) skips a single
* rule so its own name is not treated as a self-collision on update. Callers MUST pass a prefix escaped
* via {@link AutomationRuleNames#likePrefix(String)} so LIKE metacharacters in the name are matched
* literally. Final precise matching is done in Java over the returned candidate set.
* <p>
* What actually bounds this query is the <em>project</em> filter, not the name prefix. Measured on
* MySQL 8.4 with 50k rules in one workspace: for a typical project (~100 rules) the optimizer drives
* from {@code automation_rule_projects} on {@code project_id} and reaches {@code automation_rules} by
* primary key, so the name is only a residual filter. The {@code (workspace_id, name)} index added in
* migration 000092 is <em>not</em> selected in either that case or the skewed one (a single project
* holding 20k rules, where the optimizer scans ~25k rows via {@code automation_rules_idx} instead).
* Forcing the index does produce a better plan (covering range scan, half the rows), so the index is
* usable but currently inert - see the OPIK-7371 review thread before relying on it.
* <p>
* Assumptions (optimistic, per OPIK-7371): the junction backfill is complete, so a rare un-backfilled
* legacy rule (no junction row) may be missed - degrading to a duplicate name, not an error; and
Comment thread
andrescrz marked this conversation as resolved.
* concurrent creates of the same name race without a DB constraint.
*/
@SqlQuery("""
SELECT DISTINCT rule.name
FROM automation_rules rule
JOIN automation_rule_projects arp ON rule.id = arp.rule_id
WHERE rule.workspace_id = :workspaceId
AND arp.project_id IN (<projectIds>)
AND rule.name LIKE concat(:namePrefix, '%') ESCAPE '!'
<if(excludeRuleId)> AND rule.id != :excludeRuleId <endif>
Comment thread
awkoy marked this conversation as resolved.
""")
@UseStringTemplateEngine
@AllowUnusedBindings
Set<String> findCandidateNames(
@Define("projectIds") @BindList(onEmpty = BindList.EmptyHandling.NULL_VALUE, value = "projectIds") Set<UUID> projectIds,
@Bind("workspaceId") String workspaceId,
@Bind("namePrefix") String namePrefix,
@Define("excludeRuleId") @Bind("excludeRuleId") UUID excludeRuleId);

/**
* Returns the currently stored name of a rule, or empty if it does not exist. Used on update to skip
* name resolution entirely for non-name edits (OPIK-7371).
* <p>
* Deliberately a single-column projection rather than the full rule payload: the registered
* {@link AutomationRuleRowMapper} dispatches on {@code action} to {@link AutomationRuleEvaluatorModel},
* so returning a whole rule would require joining {@code automation_rule_evaluators} and deserializing
* the {@code code} payload (the full LLM-as-judge prompt) on every update just to read this one column.
*/
@SqlQuery("SELECT name FROM automation_rules WHERE id = :id AND workspace_id = :workspaceId")
Optional<String> findNameById(@Bind("id") UUID id, @Bind("workspaceId") String workspaceId);
Comment thread
awkoy marked this conversation as resolved.

@SqlUpdate("""
UPDATE automation_rules
SET name = :name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,19 @@ public <E, F extends Filter, T extends AutomationRuleEvaluator<E, F>> T save(@No
var savedEvaluator = template.inTransaction(WRITE, handle -> {
var evaluatorsDAO = handle.attach(AutomationRuleEvaluatorDAO.class);
var projectsDAO = handle.attach(AutomationRuleProjectsDAO.class);
var ruleDAO = handle.attach(AutomationRuleDAO.class);

// Auto-suffix the name when it collides with existing rules in the same project(s) (OPIK-7371).
// Names are not unique at the DB layer, so re-running an SDK script would otherwise create
// rules that are indistinguishable in the UI.
String requestedName = inputRuleEvaluator.getName();
String uniqueName = resolveUniqueName(ruleDAO, requestedName, projectIds, workspaceId, null);

Comment thread
andrescrz marked this conversation as resolved.
AutomationRuleEvaluatorModel<?> evaluator = switch (inputRuleEvaluator) {
case AutomationRuleEvaluatorLlmAsJudge llmAsJudge -> {
var definition = llmAsJudge.toBuilder()
.id(id)
.name(uniqueName)
.projectId(primaryProjectId)
.createdBy(userName)
.lastUpdatedBy(userName)
Comment thread
andrescrz marked this conversation as resolved.
Expand All @@ -145,6 +153,7 @@ public <E, F extends Filter, T extends AutomationRuleEvaluator<E, F>> T save(@No
}
var definition = userDefinedMetricPython.toBuilder()
.id(id)
.name(uniqueName)
.projectId(primaryProjectId)
.createdBy(userName)
.lastUpdatedBy(userName)
Expand All @@ -155,6 +164,7 @@ public <E, F extends Filter, T extends AutomationRuleEvaluator<E, F>> T save(@No
case AutomationRuleEvaluatorTraceThreadLlmAsJudge traceThreadLlmAsJudge -> {
var definition = traceThreadLlmAsJudge.toBuilder()
.id(id)
.name(uniqueName)
.projectId(primaryProjectId)
.createdBy(userName)
.lastUpdatedBy(userName)
Expand All @@ -168,6 +178,7 @@ public <E, F extends Filter, T extends AutomationRuleEvaluator<E, F>> T save(@No
}
var definition = userDefinedMetricPython.toBuilder()
.id(id)
.name(uniqueName)
.projectId(primaryProjectId)
.createdBy(userName)
.lastUpdatedBy(userName)
Expand All @@ -178,6 +189,7 @@ public <E, F extends Filter, T extends AutomationRuleEvaluator<E, F>> T save(@No
case AutomationRuleEvaluatorSpanLlmAsJudge spanLlmAsJudge -> {
var definition = spanLlmAsJudge.toBuilder()
.id(id)
.name(uniqueName)
.projectId(primaryProjectId)
.createdBy(userName)
.lastUpdatedBy(userName)
Expand All @@ -191,6 +203,7 @@ public <E, F extends Filter, T extends AutomationRuleEvaluator<E, F>> T save(@No
}
var definition = spanUserDefinedMetricPython.toBuilder()
.id(id)
.name(uniqueName)
.projectId(primaryProjectId)
.createdBy(userName)
.lastUpdatedBy(userName)
Expand Down Expand Up @@ -226,9 +239,35 @@ public <E, F extends Filter, T extends AutomationRuleEvaluator<E, F>> T save(@No
}
});

logSuffixApplied(inputRuleEvaluator.getName(), savedEvaluator.name(), workspaceId);

return findById(savedEvaluator.id(), savedEvaluator.projectIds(), workspaceId);
}

/**
* Resolves a name that is free within the target project(s), appending a {@code -N} suffix on collision
* (OPIK-7371). Shared by create and update so the suffixing rules cannot drift between them.
* {@code excludeRuleId} is null on create; on update it is the rule being edited, so it is not treated
* as colliding with its own current name.
*/
private String resolveUniqueName(AutomationRuleDAO ruleDAO, String requestedName, Set<UUID> projectIds,
String workspaceId, UUID excludeRuleId) {
// Only names sharing the requested prefix are fetched, so the candidate set stays small.
Set<String> candidates = ruleDAO.findCandidateNames(projectIds, workspaceId,
AutomationRuleNames.likePrefix(requestedName), excludeRuleId);
return AutomationRuleNames.generateUniqueName(requestedName, candidates);
}

// Logged after the write transaction commits (not inside it) so a rolled-back write never leaves a
// misleading line behind. Values trail the fixed text so the prefix stays greppable in production.
private void logSuffixApplied(String requestedName, String appliedName, String workspaceId) {
if (appliedName != null && !appliedName.equals(requestedName)) {
log.info("Automation rule name already existed in project scope, stored under a new name: "
+ "requestedName '{}', appliedName '{}', workspaceId '{}'",
requestedName, appliedName, workspaceId);
Comment thread
awkoy marked this conversation as resolved.
}
}

@Override
@CacheEvict(name = "automation_rule_evaluators_find_all", key = "'*-' + $workspaceId + '-*'", keyUsesPatternMatching = true)
public void update(@NonNull UUID id, @NonNull Set<UUID> projectIds, @NonNull String workspaceId,
Expand All @@ -239,18 +278,29 @@ public void update(@NonNull UUID id, @NonNull Set<UUID> projectIds, @NonNull Str
log.debug("Updating AutomationRuleEvaluator with id '{}' in projectIds '{}' and workspaceId '{}'", id,
projectIds,
workspaceId);
template.inTransaction(WRITE, handle -> {
String requestedName = evaluatorUpdate.getName();
String appliedName = template.inTransaction(WRITE, handle -> {
var dao = handle.attach(AutomationRuleEvaluatorDAO.class);
var projectsDAO = handle.attach(AutomationRuleProjectsDAO.class);
var ruleDAO = handle.attach(AutomationRuleDAO.class);

try {
String filtersJson = AutomationModelEvaluatorMapper.INSTANCE.map(evaluatorUpdate.getFilters());

// Only resolve a unique name on an actual rename. A non-name edit (sampling rate, enabled,
// filters) must never rename the rule, even if a same-named rule already exists in the
// project (e.g. legacy duplicates). This guard stays outside resolveUniqueName because it is
// specific to update - a create has no current name to compare against (OPIK-7371).
String currentName = ruleDAO.findNameById(id, workspaceId).orElse(null);
String uniqueName = Objects.equals(requestedName, currentName)
? requestedName
: resolveUniqueName(ruleDAO, requestedName, projectIds, workspaceId, id);

// Update base rule (project associations handled separately in junction table)
var triggerScope = evaluatorUpdate.getTriggerScope() != null
? evaluatorUpdate.getTriggerScope()
: EvalTriggerScope.PRODUCTION;
int resultBase = dao.updateBaseRule(id, workspaceId, evaluatorUpdate.getName(),
int resultBase = dao.updateBaseRule(id, workspaceId, uniqueName,
evaluatorUpdate.getSamplingRate(), evaluatorUpdate.isEnabled(),
triggerScope, filtersJson);

Expand Down Expand Up @@ -316,6 +366,8 @@ public void update(@NonNull UUID id, @NonNull Set<UUID> projectIds, @NonNull Str
if (resultEval == 0 || resultBase == 0) {
throw newNotFoundException();
}

return uniqueName;
} catch (UnableToExecuteStatementException e) {
if (e.getCause() instanceof SQLIntegrityConstraintViolationException) {
log.info(EVALUATOR_ALREADY_EXISTS);
Expand All @@ -324,9 +376,9 @@ public void update(@NonNull UUID id, @NonNull Set<UUID> projectIds, @NonNull Str
throw e;
}
}

return null;
});

logSuffixApplied(requestedName, appliedName, workspaceId);
}

@Override
Expand Down
Loading
Loading