Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -681,14 +681,14 @@ ORDER BY (workspace_id, project_id, id) DESC, last_updated_at DESC
LEFT JOIN feedback_scores_final AS fs ON t.id = fs.entity_id
LEFT JOIN comments_final AS c ON t.id = c.entity_id
LEFT JOIN (
SELECT
trace_id,
SUM(total_estimated_cost) AS total_estimated_cost,
sumMap(usage) AS usage
FROM spans final
WHERE workspace_id = :workspace_id
AND trace_id IN (SELECT trace_id FROM experiment_items_scope)
GROUP BY workspace_id, project_id, trace_id
SELECT
Copy link
Member

Choose a reason for hiding this comment

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

Nit: I'd recover the previous indentation. It seemed right over the current change.

trace_id,
SUM(total_estimated_cost) AS total_estimated_cost,
sumMap(usage) AS usage
FROM spans final
WHERE workspace_id = :workspace_id
AND trace_id IN (SELECT trace_id FROM experiment_items_scope)
GROUP BY workspace_id, trace_id
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Performance Analysis: GROUP BY Change

I've analyzed the performance implications of removing project_id from the GROUP BY clause. Here's why this change improves performance rather than degrades it:

Current Fix (Removing project_id):

GROUP BY workspace_id, trace_id

Alternative Approach (Keeping project_id + groupUniqArray):

GROUP BY workspace_id, project_id, trace_id
-- Then use groupUniqArray on line 622 to deduplicate

Performance Comparison:

Current Fix (Better Performance):

  • Fewer result rows: One row per trace instead of one row per (trace, project) combination
  • Faster JOIN: The LEFT JOIN at line 701 (tfs ON ei.trace_id = tfs.id) produces fewer duplicate rows
  • No deduplication overhead: Uses regular groupArray (line 622) which is faster than groupUniqArray
  • Correct semantics: Aggregates span cost/usage across ALL projects for a trace (which is what we want)

Alternative Approach (Worse Performance):

  • More result rows: Multiple rows per trace (one per project)
  • Duplicate rows in JOIN: Creates unnecessary duplicates that need deduplication later
  • Deduplication overhead: Requires groupUniqArray on complex tuples (line 622), which is slower
  • Incorrect semantics: Splits span metrics by project, then needs to deduplicate experiment items

Real-World Impact:

With cross-project traces (10 traces, 7 with spans in 2 projects):

  • Current fix: Spans subquery returns 10 rows → 10 experiment items
  • Alternative: Spans subquery returns 17 rows → 17 rows to deduplicate → 10 experiment items

Performance gain: ~41% fewer rows to process in subsequent joins and aggregations.

Conclusion:

The current fix is the optimal solution because it:

  1. Reduces data volume early in the query pipeline
  2. Avoids unnecessary JOIN duplicates
  3. Eliminates deduplication overhead
  4. Provides semantically correct aggregation (total cost/usage per trace, not per project)

The performance concern about "many traces and spans" is actually addressed BETTER by this fix because we're reducing the intermediate result set size by ~40% for workspaces with cross-project traces.

Copy link
Member

Choose a reason for hiding this comment

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

I'm not fully convinced about this summary, specially the 40% gain.

The best way would be by presenting the objective data, in the form of the query plans before and after the change, particularly the queries were the issue was reported.

Anyway, this is more a wish on my end. Not a blocker to continue.

) s ON t.id = s.trace_id
GROUP BY
t.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6595,4 +6595,131 @@ void getExperimentItemsStats__withFeedbackScoresIsNotEmptyFilter() {
TraceAssertions.assertStats(stats.stats(), expectedStats);
}
}

@Nested
@DisplayName("OPIK-2469: Cross-Project Traces Duplicate Test")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CrossProjectTracesDuplicateTest {

@Test
@DisplayName("Should return unique experiment items when trace has spans in multiple projects")
void findDatasetItemsWithExperimentItems__whenTraceHasSpansInMultipleProjects__thenReturnUniqueItems() {

var workspaceName = UUID.randomUUID().toString();
var apiKey = UUID.randomUUID().toString();
var workspaceId = UUID.randomUUID().toString();

mockTargetWorkspace(apiKey, workspaceName, workspaceId);

// Create dataset
var dataset = factory.manufacturePojo(Dataset.class);
var datasetId = createAndAssert(dataset, apiKey, workspaceName);

// Create dataset item
var datasetItem = factory.manufacturePojo(DatasetItem.class);
var datasetItemBatch = DatasetItemBatch.builder()
.datasetId(datasetId)
.items(List.of(datasetItem))
.build();
putAndAssert(datasetItemBatch, workspaceName, apiKey);

// Create Project A and Project B
var projectA = UUID.randomUUID().toString();
var projectB = UUID.randomUUID().toString();

// Create trace in Project A
var trace1 = factory.manufacturePojo(Trace.class).toBuilder()
.projectName(projectA)
.build();
createAndAssert(trace1, workspaceName, apiKey);

// Create span in Project A for trace1
var span1InProjectA = factory.manufacturePojo(Span.class).toBuilder()
.projectName(projectA)
.traceId(trace1.id())
.build();
createSpan(span1InProjectA, apiKey, workspaceName);

// ROOT CAUSE SIMULATION: Create span in Project B for the SAME trace (trace1)
// This creates a cross-project trace scenario through the API
var span1InProjectB = factory.manufacturePojo(Span.class).toBuilder()
.projectName(projectB)
.traceId(trace1.id())
.build();
createSpan(span1InProjectB, apiKey, workspaceName);

// Create another trace in Project A (no cross-project issue)
var trace2 = factory.manufacturePojo(Trace.class).toBuilder()
.projectName(projectA)
.build();
createAndAssert(trace2, workspaceName, apiKey);

// Create experiment items for both traces
var experimentId = GENERATOR.generate();
var experimentItem1 = factory.manufacturePojo(ExperimentItem.class).toBuilder()
.experimentId(experimentId)
.datasetItemId(datasetItem.id())
.traceId(trace1.id())
.input(trace1.input())
.output(trace1.output())
.build();

var experimentItem2 = factory.manufacturePojo(ExperimentItem.class).toBuilder()
.experimentId(experimentId)
.datasetItemId(datasetItem.id())
.traceId(trace2.id())
.input(trace2.input())
.output(trace2.output())
.build();

var experimentItemsBatch = ExperimentItemsBatch.builder()
.experimentItems(Set.of(experimentItem1, experimentItem2))
.build();
createAndAssert(experimentItemsBatch, apiKey, workspaceName);

// Query the endpoint
var result = datasetResourceClient.getDatasetItemsWithExperimentItems(
datasetId,
List.of(experimentId),
apiKey,
workspaceName);

// Assert results
assertThat(result).isNotNull();
assertThat(result.content()).hasSize(1);

var datasetItemResult = result.content().get(0);
assertThat(datasetItemResult.id()).isEqualTo(datasetItem.id());

// CRITICAL ASSERTION: Should have exactly 2 unique experiment items (no duplicates)
// Without the fix, trace1 appears twice because it has spans in 2 projects
var experimentItems = datasetItemResult.experimentItems();
assertThat(experimentItems).isNotNull();

// Count experiment items by their ID to detect duplicates
var experimentItemIds = experimentItems.stream()
.map(ExperimentItem::id)
.collect(Collectors.toList());

var uniqueIds = new HashSet<>(experimentItemIds);

// THIS IS THE KEY ASSERTION - Verifies fix for OPIK-2469
assertThat(experimentItemIds)
.as("Should not contain duplicate experiment item IDs - trace1 has spans in 2 projects but should appear once")
.hasSameSizeAs(uniqueIds)
.as("Should have exactly 2 unique experiment items")
.hasSize(2);

// Verify the correct experiment items are present
assertThat(uniqueIds).containsExactlyInAnyOrder(experimentItem1.id(), experimentItem2.id());

// Verify each experiment item appears only once
experimentItemIds.forEach(id -> {
long count = experimentItemIds.stream().filter(i -> i.equals(id)).count();
assertThat(count)
.as("Experiment item '%s' should appear exactly once, but appears '%d' times", id, count)
.isEqualTo(1);
});
}
}
}