Skip to content

Add support for multiple datasource - #27

Open
khansaad wants to merge 4 commits into
kruize:mvp_demofrom
khansaad:add-multi-ds-support
Open

Add support for multiple datasource#27
khansaad wants to merge 4 commits into
kruize:mvp_demofrom
khansaad:add-multi-ds-support

Conversation

@khansaad

@khansaad khansaad commented Jun 12, 2026

Copy link
Copy Markdown

This PR adds support for adding multiple datasources as part of experiment creation.

Summary by Sourcery

Add multi-datasource support to the bulk experiment scheduler while preserving backward compatibility with the existing single-datasource configuration.

New Features:

  • Allow configuring multiple default datasources via a new comma-separated configuration property and environment variable.
  • Include a list of datasources in the bulk experiment creation payload instead of a single datasource field when available.

Enhancements:

  • Preserve the legacy single-datasource configuration as a backward-compatible fallback that maps to the first datasource in the list API.
  • Improve bulk scheduler error handling and logging to explicitly cover the case where no datasources are configured.

Build:

  • Extend deployment manifests and application configuration to surface both legacy and new multi-datasource environment variables.

Tests:

  • Update and extend bulk scheduler tests to validate payload structure, multi-datasource ordering, and behavior when no datasources are configured.

Signed-off-by: Saad Khan <saakhan@ibm.com>
@khansaad khansaad self-assigned this Jun 12, 2026
@khansaad khansaad added the enhancement New feature or request label Jun 12, 2026
@sourcery-ai

sourcery-ai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds multi-datasource support to bulk experiment creation by introducing a list-based datasource configuration and wiring it through state resolution, payload construction, configuration, and deployment manifests while preserving backward compatibility with the legacy single-datasource setting.

Sequence diagram for multi-datasource bulk experiment creation flow

sequenceDiagram
    participant BulkSchedulerService
    participant KruizeStateService
    participant BulkAPI

    BulkSchedulerService->>KruizeStateService: refreshState()
    KruizeStateService-->>BulkSchedulerService: (state refreshed)

    BulkSchedulerService->>KruizeStateService: getDefaultDatasourceNames()
    KruizeStateService-->>BulkSchedulerService: List<String> datasourceNames

    BulkSchedulerService->>BulkSchedulerService: buildBulkPayload(targetLabels, datasourceNames, metadataProfile, metricProfile)

    BulkSchedulerService->>BulkAPI: POST /bulk (payload with datasources)
Loading

File-Level Changes

Change Details Files
Introduce list-based default datasource resolution with backward-compatible single-datasource support.
  • Add new optional kruize.defaults.datasources configuration property for comma-separated datasource names.
  • Implement getDefaultDatasourceNames() in KruizeStateService to derive an ordered list from legacy single value, new list config, or cached datasources fallback.
  • Deprecate getDefaultDatasourceName() and reimplement it in terms of getDefaultDatasourceNames() for compatibility.
  • Add debug logging around datasource resolution and returned list.
src/main/java/com/kruize/optimizer/service/KruizeStateService.java
Update bulk scheduler to use multiple datasources and new payload structure.
  • Switch bulk scheduler to fetch datasource list via getDefaultDatasourceNames() instead of a single Optional.
  • Guard scheduled bulk API execution on non-empty datasource list and log a new ERROR_NO_DATASOURCES_AVAILABLE message when empty.
  • Change buildBulkPayload to accept a list of datasources, populating the new datasources field and avoiding use of deprecated datasource when a list is present.
  • Keep a defensive fallback that writes a null datasource field when the list is unexpectedly empty.
src/main/java/com/kruize/optimizer/service/BulkSchedulerService.java
src/main/java/com/kruize/optimizer/utils/OptimizerConstants.java
Expand bulk scheduler tests to validate multi-datasource handling and new payload semantics.
  • Update existing success-path tests to use getDefaultDatasourceNames() and assert on the datasources field instead of the single datasource field.
  • Add a new test covering multiple datasources to verify ordering, payload structure, and job counter behavior.
  • Adjust the no-datasource test to simulate an empty datasource list and ensure neither the bulk API nor jobs counter are invoked.
  • Update cache-refresh and exception-handling tests to work with the list-based datasource API.
src/test/java/com/kruize/optimizer/service/BulkSchedulerServiceTest.java
Wire new datasource configuration into application configuration and deployment manifests.
  • Add kruize.defaults.datasources to application.yml with precedence over the legacy single datasource setting and document deprecation.
  • Introduce KRUIZE_DATASOURCES/KRUIZE_DEFAULT_DATASOURCES environment variables in base and overlay manifests for multi-datasource configuration, marking KRUIZE_DEFAULT_DATASOURCE as deprecated.
  • Provide example comma-separated datasource values and comments explaining precedence and usage across base, kind, and openshift overlays.
src/main/resources/application.yml
deployment/base/deployment.yaml
deployment/overlays/kind/kustomization.yaml
deployment/overlays/openshift/kustomization.yaml
Extend constants to reflect multi-datasource support and new error message.
  • Add BulkSchedulerConstants.DATASOURCES for the new list-based datasource key while marking DATASOURCE as deprecated.
  • Introduce MessageConstants.ERROR_NO_DATASOURCES_AVAILABLE for logging when no datasources are resolved.
src/main/java/com/kruize/optimizer/utils/OptimizerConstants.java

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@khansaad khansaad moved this to Under Review in Monitoring Jun 12, 2026

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

Hey - I've found 2 issues, and left some high level feedback:

  • In buildBulkPayload, consider populating both BulkSchedulerConstants.DATASOURCES and the legacy BulkSchedulerConstants.DATASOURCE (e.g., first entry) to preserve backward compatibility for any consumers still expecting the singular datasource field.
  • In getDefaultDatasourceNames(), you're trimming entries only for the new defaultDatasources config but not for the legacy defaultDatasource; aligning this (e.g., trimming defaultDatasource before comparison) would avoid subtle mismatches when users accidentally include whitespace in the legacy env var.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `buildBulkPayload`, consider populating both `BulkSchedulerConstants.DATASOURCES` and the legacy `BulkSchedulerConstants.DATASOURCE` (e.g., first entry) to preserve backward compatibility for any consumers still expecting the singular `datasource` field.
- In `getDefaultDatasourceNames()`, you're trimming entries only for the new `defaultDatasources` config but not for the legacy `defaultDatasource`; aligning this (e.g., trimming `defaultDatasource` before comparison) would avoid subtle mismatches when users accidentally include whitespace in the legacy env var.

## Individual Comments

### Comment 1
<location path="src/main/java/com/kruize/optimizer/service/BulkSchedulerService.java" line_range="215-216" />
<code_context>

-        // Add datasource from global state
-        payload.put(BulkSchedulerConstants.DATASOURCE, datasource);
+        // Add datasources list from global state
+        payload.put(BulkSchedulerConstants.DATASOURCES, datasources);

         // Add metadata profile from global state
</code_context>
<issue_to_address>
**issue (bug_risk):** Consider populating both `datasources` and deprecated `datasource` keys for backward compatibility

The constants still define `DATASOURCE` as deprecated for backward compatibility, but the payload now only sets `DATASOURCES`. Existing consumers that rely on the singular field may break. To avoid this, set both for now, e.g.:

```java
payload.put(BulkSchedulerConstants.DATASOURCES, datasources);
if (!datasources.isEmpty()) {
    payload.put(BulkSchedulerConstants.DATASOURCE, datasources.get(0));
}
```
</issue_to_address>

### Comment 2
<location path="src/main/java/com/kruize/optimizer/service/KruizeStateService.java" line_range="175-166" />
<code_context>
-        return cachedDatasources.stream()
-                .findFirst()
-                .map(Datasource::getName);
+        // Fall back to old single datasource config if new config is empty
+        if (result.isEmpty() && defaultDatasource != null && !defaultDatasource.trim().isEmpty()) {
+            boolean exists = cachedDatasources.stream()
+                    .anyMatch(ds -> defaultDatasource.equals(ds.getName()));
+            if (exists) {
</code_context>
<issue_to_address>
**issue (bug_risk):** Normalize `defaultDatasource` before comparison to avoid subtle mismatch cases

When falling back to the single `defaultDatasource`, you trim only for the emptiness check but compare the untrimmed value against `ds.getName()`. A value with leading/trailing spaces will pass the check but never match. Trim once and reuse the trimmed value for both checks and comparison, e.g.:

```java
String defaultName = defaultDatasource == null ? null : defaultDatasource.trim();
if (result.isEmpty() && defaultName != null && !defaultName.isEmpty()) {
    boolean exists = cachedDatasources.stream()
            .anyMatch(ds -> defaultName.equals(ds.getName()));
    if (exists) {
        result.add(defaultName);
    }
}
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +215 to +216
// Add datasources list from global state
payload.put(BulkSchedulerConstants.DATASOURCES, datasources);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Consider populating both datasources and deprecated datasource keys for backward compatibility

The constants still define DATASOURCE as deprecated for backward compatibility, but the payload now only sets DATASOURCES. Existing consumers that rely on the singular field may break. To avoid this, set both for now, e.g.:

payload.put(BulkSchedulerConstants.DATASOURCES, datasources);
if (!datasources.isEmpty()) {
    payload.put(BulkSchedulerConstants.DATASOURCE, datasources.get(0));
}

Comment thread src/main/java/com/kruize/optimizer/service/KruizeStateService.java Outdated
khansaad added 2 commits June 12, 2026 23:21
Signed-off-by: Saad Khan <saakhan@ibm.com>
Signed-off-by: Saad Khan <saakhan@ibm.com>
Comment thread deployment/base/deployment.yaml Outdated
@khansaad

Copy link
Copy Markdown
Author

@sourcery-ai review

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

Hey - I've found 6 issues, and left some high level feedback:

  • The configuration and comments are inconsistent between KRUIZE_DATASOURCES and KRUIZE_DEFAULT_DATASOURCES (e.g., application.yml uses KRUIZE_DATASOURCES, but the base deployment sets KRUIZE_DEFAULT_DATASOURCES), which will result in the multi-datasource config not being picked up in some environments; align the env var name and references across code, YAML, and manifests.
  • BulkScheduler now only emits the datasources field in the payload while BulkSchedulerConstants still exposes DATASOURCE for backwards compatibility; if existing consumers expect datasource, consider populating both keys (at least when a single datasource is configured) to avoid breaking older clients.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The configuration and comments are inconsistent between `KRUIZE_DATASOURCES` and `KRUIZE_DEFAULT_DATASOURCES` (e.g., `application.yml` uses `KRUIZE_DATASOURCES`, but the base deployment sets `KRUIZE_DEFAULT_DATASOURCES`), which will result in the multi-datasource config not being picked up in some environments; align the env var name and references across code, YAML, and manifests.
- BulkScheduler now only emits the `datasources` field in the payload while `BulkSchedulerConstants` still exposes `DATASOURCE` for backwards compatibility; if existing consumers expect `datasource`, consider populating both keys (at least when a single datasource is configured) to avoid breaking older clients.

## Individual Comments

### Comment 1
<location path="deployment/base/deployment.yaml" line_range="68-75" />
<code_context>
               value: '{"kruize/autotune": "enabled"}'

             # Default Configuration
+            # Default datasource name (deprecated - use KRUIZE_DEFAULT_DATASOURCES for multi-datasource support)
+            - name: KRUIZE_DEFAULT_DATASOURCE
+              value: "prometheus-1"
+            
+            # Default datasources (comma-separated list for multi-datasource support)
+            # Takes precedence over single datasource config
+            # Example: "prometheus-1,prometheus-2"
+            - name: KRUIZE_DEFAULT_DATASOURCES
+              value: "prometheus-1"
+            
</code_context>
<issue_to_address>
**issue (bug_risk):** Align environment variable name with the one used in application.yml (`KRUIZE_DATASOURCES` vs `KRUIZE_DEFAULT_DATASOURCES`).

`application.yml` reads `kruize.defaults.datasources: ${KRUIZE_DATASOURCES:}`, but this deployment only defines `KRUIZE_DEFAULT_DATASOURCES`. As a result, the multi-datasource configuration here will never be used. Please either rename this env var to `KRUIZE_DATASOURCES` (and update the comment) or change the application config to read from `KRUIZE_DEFAULT_DATASOURCES`, depending on which name you intend to standardize on.
</issue_to_address>

### Comment 2
<location path="src/main/java/com/kruize/optimizer/service/KruizeStateService.java" line_range="203-212" />
<code_context>
+     * Get the default datasource name (for operations that need a single datasource)
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Behavior of `getDefaultDatasourceName` does not fully match the documented defaulting semantics.

The method Javadoc and `application.yml` comment say that when multiple datasources exist and no default is configured, the default should be `"prometheus-1"`. The current implementation instead uses the first entry in `allDatasources` when `defaultDatasource` is unset/blank, which may differ from `"prometheus-1"`. Either update the logic to explicitly prefer `"prometheus-1"` when present (falling back to the first element otherwise), or update the comments/config docs to reflect the actual behavior.

Suggested implementation:

```java
     *
     * Logic:
     * - If KRUIZE_DATASOURCES has only one datasource, return that
     * - If KRUIZE_DATASOURCES has multiple datasources:
     *   - If KRUIZE_DEFAULT_DATASOURCE is set, return that
     *   - If KRUIZE_DEFAULT_DATASOURCE is not set or blank, return the first configured datasource
     * - If KRUIZE_DATASOURCES is not set:
     *   - If KRUIZE_DEFAULT_DATASOURCE is set, return that
     *   - If KRUIZE_DEFAULT_DATASOURCE is not set or blank, return empty
     *

```

The current change updates the Javadoc to accurately describe the existing behavior (using the first configured datasource when multiple exist and no default is set).
If you instead choose to align the implementation with the documented `"prometheus-1"` behavior, you should:
1. Update `getDefaultDatasourceName()` implementation to:
   - If multiple datasources exist and `KRUIZE_DEFAULT_DATASOURCE` is unset/blank, first look for a datasource named `"prometheus-1"` and return it if present.
   - If `"prometheus-1"` is not present, fall back to the current behavior (first configured datasource).
2. Optionally, extract `"prometheus-1"` into a constant (e.g. `DEFAULT_DATASOURCE_NAME`) in your configuration or constants class and reference that instead of a string literal, to keep it consistent with `application.yml`.
</issue_to_address>

### Comment 3
<location path="src/main/java/com/kruize/optimizer/service/BulkSchedulerService.java" line_range="215-216" />
<code_context>

-        // Add datasource from global state
-        payload.put(BulkSchedulerConstants.DATASOURCE, datasource);
+        // Add datasources list from global state
+        payload.put(BulkSchedulerConstants.DATASOURCES, datasources);

         // Add metadata profile from global state
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Consider populating both `datasources` (new) and `datasource` (deprecated) in the payload for backward compatibility.

The new code only populates `DATASOURCES`, so the legacy `DATASOURCE` field is no longer sent even though the constant still exists. If any current consumers still depend on `datasource`, this will be a breaking change. Please consider populating both for now (e.g., set `DATASOURCE` from the primary entry in `datasources`) and deprecate `DATASOURCE` once all consumers are updated.
</issue_to_address>

### Comment 4
<location path="src/test/java/com/kruize/optimizer/service/BulkSchedulerServiceTest.java" line_range="100-103" />
<code_context>
         // Arrange
         when(kruizeStateService.isCacheEmpty()).thenReturn(false);
-        when(kruizeStateService.getDefaultDatasourceName()).thenReturn(Optional.of("prometheus-1"));
+        when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(List.of("prometheus-1"));
         when(kruizeStateService.getDefaultMetadataProfileName()).thenReturn(Optional.of("cluster-metadata-local-monitoring"));
         when(kruizeStateService.getDefaultMetricProfileName()).thenReturn(Optional.of("resource-optimization-local-monitoring"));
</code_context>
<issue_to_address>
**suggestion (testing):** Add a dedicated test case for multiple datasources to validate the new list-based behavior.

The current "happy path" test still only covers a single datasource. Since `BulkSchedulerService` now operates on a list, please add a separate test (e.g. `testScheduledBulkApiCall_MultipleDatasources`) where `getDefaultDatasourceNames()` returns multiple entries (e.g. `List.of("prometheus-1", "prometheus-2")`) and assert that:

- The payload contains the complete datasource list (and order, if relevant).
- The list passed to `kruizeClient.bulkCreateExperiments` matches what `kruizeStateService` returned.

This ensures the multi-datasource path is correctly wired and guards against regressions that only use the first datasource.

Suggested implementation:

```java
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.mockito.ArgumentCaptor;

```

```java
    void testScheduledBulkApiCall_Success() {
        // Arrange
        when(kruizeStateService.isCacheEmpty()).thenReturn(false);
        when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(List.of("prometheus-1"));
        when(kruizeStateService.getDefaultMetadataProfileName()).thenReturn(Optional.of("cluster-metadata-local-monitoring"));
        when(kruizeStateService.getDefaultMetricProfileName()).thenReturn(Optional.of("resource-optimization-local-monitoring"));
        when(kruizeClient.bulkCreateExperiments(any())).thenReturn(mockBulkApiResponse);

        assertNotNull(payload);
        assertTrue(payload.containsKey("filter"));
        assertTrue(payload.containsKey("datasources"));
        assertTrue(payload.containsKey("metadata_profile"));
        assertTrue(payload.containsKey("measurement_duration"));
        assertEquals(List.of("prometheus-1"), payload.get("datasources"));
    }

    @Test
    void testScheduledBulkApiCall_MultipleDatasources() {
        // Arrange
        when(kruizeStateService.isCacheEmpty()).thenReturn(false);
        List<String> datasources = List.of("prometheus-1", "prometheus-2");
        when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(datasources);
        when(kruizeStateService.getDefaultMetadataProfileName())
                .thenReturn(Optional.of("cluster-metadata-local-monitoring"));
        when(kruizeStateService.getDefaultMetricProfileName())
                .thenReturn(Optional.of("resource-optimization-local-monitoring"));
        when(kruizeClient.bulkCreateExperiments(any())).thenReturn(mockBulkApiResponse);

        // Act
        bulkSchedulerService.scheduledBulkApiCall();

        // Assert
        ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
        verify(kruizeClient).bulkCreateExperiments(payloadCaptor.capture());

        Map<String, Object> payload = payloadCaptor.getValue();
        assertNotNull(payload);
        assertTrue(payload.containsKey("filter"));
        assertTrue(payload.containsKey("datasources"));
        assertTrue(payload.containsKey("metadata_profile"));
        assertTrue(payload.containsKey("measurement_duration"));

        // Ensure the full list of datasources is propagated and ordered correctly
        assertEquals(datasources, payload.get("datasources"));

```

1. This new test assumes:
   - `bulkSchedulerService` and `kruizeClient` are already initialized and mocked as in the existing tests.
   - The `bulkCreateExperiments` method accepts a single payload `Map<String, Object>`; if it instead accepts a list or another type, adjust the `ArgumentCaptor` generic type and the verification accordingly.
2. If the test class does not already use JUnit 5, replace `@Test` with the appropriate annotation/import for your JUnit version.
3. If `payload` in the existing success test is produced via a shared helper or field rather than an `ArgumentCaptor`, you can align the new test with that pattern by reusing the same mechanism instead of introducing a new captor.
</issue_to_address>

### Comment 5
<location path="src/test/java/com/kruize/optimizer/service/BulkSchedulerServiceTest.java" line_range="161-164" />
<code_context>
+     * - Service logs error about missing datasources
      */
     @Test
     void testScheduledBulkApiCall_NoDatasource() {
         // Arrange
         when(kruizeStateService.isCacheEmpty()).thenReturn(false);
-        when(kruizeStateService.getDefaultDatasourceName()).thenReturn(Optional.empty());
+        when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(Collections.emptyList());

         // Act
</code_context>
<issue_to_address>
**suggestion (testing):** Consider asserting the new error message for missing datasources to fully validate the failure path.

Since the implementation now logs `MessageConstants.ERROR_NO_DATASOURCES_AVAILABLE`, please extend this test to assert that this specific log message is emitted (e.g., via a log appender or logger spy), in addition to the existing checks on `bulkCreateExperiments` and the job counter. This will ensure the test also validates the updated error messaging for the multi-datasource case.

Suggested implementation:

```java
import static com.kruize.optimizer.constants.MessageConstants.ERROR_NO_DATASOURCES_AVAILABLE;

     * when no datasources are configured in Kruize.
     *
     * Expected Behavior:
     * - Bulk API not called
     * - Jobs counter not incremented
     * - Service logs error about missing datasources
     */
    @Test
    void testScheduledBulkApiCall_NoDatasource() {
        // Arrange
        when(kruizeStateService.isCacheEmpty()).thenReturn(false);
        when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(Collections.emptyList());

        // Act
        bulkSchedulerService.initialize();

        // Assert
        // - Bulk API not called
        verify(kruizeClient, never()).bulkCreateExperiments(any());
        // - Jobs counter not incremented
        assertEquals(0, bulkSchedulerService.getJobsCounter());

        // - Service logs error about missing datasources
        //   (ERROR_NO_DATASOURCES_AVAILABLE should be emitted as an ERROR log)
        boolean hasMissingDatasourceErrorLog = logAppender.list.stream()
                .anyMatch(event ->
                        event.getLevel() == ch.qos.logback.classic.Level.ERROR
                                && event.getFormattedMessage().contains(ERROR_NO_DATASOURCES_AVAILABLE));

        assertTrue(
                hasMissingDatasourceErrorLog,
                "Expected ERROR log with message: " + ERROR_NO_DATASOURCES_AVAILABLE
        );

        // Arrange
        when(kruizeStateService.isCacheEmpty()).thenReturn(true);
        doNothing().when(kruizeStateService).refreshState();
        when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(List.of("prometheus-1"));
        when(kruizeStateService.getDefaultMetadataProfileName()).thenReturn(Optional.of("cluster-metadata-local-monitoring"));
        when(kruizeStateService.getDefaultMetricProfileName()).thenReturn(Optional.of("resource-optimization-local-monitoring"));
        when(kruizeClient.bulkCreateExperiments(any())).thenReturn(mockBulkApiResponse);
    void testScheduledBulkApiCall_ExceptionHandling() {

```

To make this compile and work correctly, ensure the following in the rest of the test file:

1. **Log appender setup**  
   There must be a `logAppender` field capturing logs from `BulkSchedulerService`, e.g.:
   ```java
   private ListAppender<ILoggingEvent> logAppender;
   ```
   initialized in a `@BeforeEach` like:
   ```java
   @BeforeEach
   void setUp() {
       Logger logger = (Logger) LoggerFactory.getLogger(BulkSchedulerService.class);
       logAppender = new ListAppender<>();
       logAppender.start();
       logger.addAppender(logAppender);
       // existing setup...
   }
   ```

2. **Static imports / assertions**  
   Ensure `assertEquals` and `assertTrue` are statically imported (e.g., `import static org.junit.jupiter.api.Assertions.*;`) and `verify`, `never`, `any` are imported from Mockito if not already.

3. **Logback classes**  
   Make sure the file already uses Logback (`ListAppender`, `ILoggingEvent`, `ch.qos.logback.classic.Level`). If a different logging test utility is used in this file, adapt the `hasMissingDatasourceErrorLog` computation to that existing mechanism instead of introducing a new one.
</issue_to_address>

### Comment 6
<location path="src/test/java/com/kruize/optimizer/service/BulkSchedulerServiceTest.java" line_range="121-124" />
<code_context>
         assertNotNull(payload);
         assertTrue(payload.containsKey("filter"));
-        assertTrue(payload.containsKey("datasource"));
+        assertTrue(payload.containsKey("datasources"));
         assertTrue(payload.containsKey("metadata_profile"));
         assertTrue(payload.containsKey("measurement_duration"));
-        assertEquals("prometheus-1", payload.get("datasource"));
+        assertEquals(List.of("prometheus-1"), payload.get("datasources"));
         assertEquals("cluster-metadata-local-monitoring", payload.get("metadata_profile"));
         assertEquals(measurementDuration, payload.get("measurement_duration"));
</code_context>
<issue_to_address>
**nitpick:** Use `BulkSchedulerConstants.DATASOURCES` instead of hardcoded string keys in assertions.

Since this field now has a `BulkSchedulerConstants.DATASOURCES` constant, reference it in these assertions (e.g. `payload.containsKey(BulkSchedulerConstants.DATASOURCES)`) so the tests track the production contract if the key changes.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread deployment/base/deployment.yaml Outdated
Comment thread src/main/java/com/kruize/optimizer/service/KruizeStateService.java Outdated
Comment on lines +215 to +216
// Add datasources list from global state
payload.put(BulkSchedulerConstants.DATASOURCES, datasources);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (bug_risk): Consider populating both datasources (new) and datasource (deprecated) in the payload for backward compatibility.

The new code only populates DATASOURCES, so the legacy DATASOURCE field is no longer sent even though the constant still exists. If any current consumers still depend on datasource, this will be a breaking change. Please consider populating both for now (e.g., set DATASOURCE from the primary entry in datasources) and deprecate DATASOURCE once all consumers are updated.

Comment on lines +100 to 103
when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(List.of("prometheus-1"));
when(kruizeStateService.getDefaultMetadataProfileName()).thenReturn(Optional.of("cluster-metadata-local-monitoring"));
when(kruizeStateService.getDefaultMetricProfileName()).thenReturn(Optional.of("resource-optimization-local-monitoring"));
when(kruizeClient.bulkCreateExperiments(any())).thenReturn(mockBulkApiResponse);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Add a dedicated test case for multiple datasources to validate the new list-based behavior.

The current "happy path" test still only covers a single datasource. Since BulkSchedulerService now operates on a list, please add a separate test (e.g. testScheduledBulkApiCall_MultipleDatasources) where getDefaultDatasourceNames() returns multiple entries (e.g. List.of("prometheus-1", "prometheus-2")) and assert that:

  • The payload contains the complete datasource list (and order, if relevant).
  • The list passed to kruizeClient.bulkCreateExperiments matches what kruizeStateService returned.

This ensures the multi-datasource path is correctly wired and guards against regressions that only use the first datasource.

Suggested implementation:

import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.mockito.ArgumentCaptor;
    void testScheduledBulkApiCall_Success() {
        // Arrange
        when(kruizeStateService.isCacheEmpty()).thenReturn(false);
        when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(List.of("prometheus-1"));
        when(kruizeStateService.getDefaultMetadataProfileName()).thenReturn(Optional.of("cluster-metadata-local-monitoring"));
        when(kruizeStateService.getDefaultMetricProfileName()).thenReturn(Optional.of("resource-optimization-local-monitoring"));
        when(kruizeClient.bulkCreateExperiments(any())).thenReturn(mockBulkApiResponse);

        assertNotNull(payload);
        assertTrue(payload.containsKey("filter"));
        assertTrue(payload.containsKey("datasources"));
        assertTrue(payload.containsKey("metadata_profile"));
        assertTrue(payload.containsKey("measurement_duration"));
        assertEquals(List.of("prometheus-1"), payload.get("datasources"));
    }

    @Test
    void testScheduledBulkApiCall_MultipleDatasources() {
        // Arrange
        when(kruizeStateService.isCacheEmpty()).thenReturn(false);
        List<String> datasources = List.of("prometheus-1", "prometheus-2");
        when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(datasources);
        when(kruizeStateService.getDefaultMetadataProfileName())
                .thenReturn(Optional.of("cluster-metadata-local-monitoring"));
        when(kruizeStateService.getDefaultMetricProfileName())
                .thenReturn(Optional.of("resource-optimization-local-monitoring"));
        when(kruizeClient.bulkCreateExperiments(any())).thenReturn(mockBulkApiResponse);

        // Act
        bulkSchedulerService.scheduledBulkApiCall();

        // Assert
        ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
        verify(kruizeClient).bulkCreateExperiments(payloadCaptor.capture());

        Map<String, Object> payload = payloadCaptor.getValue();
        assertNotNull(payload);
        assertTrue(payload.containsKey("filter"));
        assertTrue(payload.containsKey("datasources"));
        assertTrue(payload.containsKey("metadata_profile"));
        assertTrue(payload.containsKey("measurement_duration"));

        // Ensure the full list of datasources is propagated and ordered correctly
        assertEquals(datasources, payload.get("datasources"));
  1. This new test assumes:
    • bulkSchedulerService and kruizeClient are already initialized and mocked as in the existing tests.
    • The bulkCreateExperiments method accepts a single payload Map<String, Object>; if it instead accepts a list or another type, adjust the ArgumentCaptor generic type and the verification accordingly.
  2. If the test class does not already use JUnit 5, replace @Test with the appropriate annotation/import for your JUnit version.
  3. If payload in the existing success test is produced via a shared helper or field rather than an ArgumentCaptor, you can align the new test with that pattern by reusing the same mechanism instead of introducing a new captor.

Comment on lines 161 to +164
void testScheduledBulkApiCall_NoDatasource() {
// Arrange
when(kruizeStateService.isCacheEmpty()).thenReturn(false);
when(kruizeStateService.getDefaultDatasourceName()).thenReturn(Optional.empty());
when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(Collections.emptyList());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Consider asserting the new error message for missing datasources to fully validate the failure path.

Since the implementation now logs MessageConstants.ERROR_NO_DATASOURCES_AVAILABLE, please extend this test to assert that this specific log message is emitted (e.g., via a log appender or logger spy), in addition to the existing checks on bulkCreateExperiments and the job counter. This will ensure the test also validates the updated error messaging for the multi-datasource case.

Suggested implementation:

import static com.kruize.optimizer.constants.MessageConstants.ERROR_NO_DATASOURCES_AVAILABLE;

     * when no datasources are configured in Kruize.
     *
     * Expected Behavior:
     * - Bulk API not called
     * - Jobs counter not incremented
     * - Service logs error about missing datasources
     */
    @Test
    void testScheduledBulkApiCall_NoDatasource() {
        // Arrange
        when(kruizeStateService.isCacheEmpty()).thenReturn(false);
        when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(Collections.emptyList());

        // Act
        bulkSchedulerService.initialize();

        // Assert
        // - Bulk API not called
        verify(kruizeClient, never()).bulkCreateExperiments(any());
        // - Jobs counter not incremented
        assertEquals(0, bulkSchedulerService.getJobsCounter());

        // - Service logs error about missing datasources
        //   (ERROR_NO_DATASOURCES_AVAILABLE should be emitted as an ERROR log)
        boolean hasMissingDatasourceErrorLog = logAppender.list.stream()
                .anyMatch(event ->
                        event.getLevel() == ch.qos.logback.classic.Level.ERROR
                                && event.getFormattedMessage().contains(ERROR_NO_DATASOURCES_AVAILABLE));

        assertTrue(
                hasMissingDatasourceErrorLog,
                "Expected ERROR log with message: " + ERROR_NO_DATASOURCES_AVAILABLE
        );

        // Arrange
        when(kruizeStateService.isCacheEmpty()).thenReturn(true);
        doNothing().when(kruizeStateService).refreshState();
        when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(List.of("prometheus-1"));
        when(kruizeStateService.getDefaultMetadataProfileName()).thenReturn(Optional.of("cluster-metadata-local-monitoring"));
        when(kruizeStateService.getDefaultMetricProfileName()).thenReturn(Optional.of("resource-optimization-local-monitoring"));
        when(kruizeClient.bulkCreateExperiments(any())).thenReturn(mockBulkApiResponse);
    void testScheduledBulkApiCall_ExceptionHandling() {

To make this compile and work correctly, ensure the following in the rest of the test file:

  1. Log appender setup
    There must be a logAppender field capturing logs from BulkSchedulerService, e.g.:

    private ListAppender<ILoggingEvent> logAppender;

    initialized in a @BeforeEach like:

    @BeforeEach
    void setUp() {
        Logger logger = (Logger) LoggerFactory.getLogger(BulkSchedulerService.class);
        logAppender = new ListAppender<>();
        logAppender.start();
        logger.addAppender(logAppender);
        // existing setup...
    }
  2. Static imports / assertions
    Ensure assertEquals and assertTrue are statically imported (e.g., import static org.junit.jupiter.api.Assertions.*;) and verify, never, any are imported from Mockito if not already.

  3. Logback classes
    Make sure the file already uses Logback (ListAppender, ILoggingEvent, ch.qos.logback.classic.Level). If a different logging test utility is used in this file, adapt the hasMissingDatasourceErrorLog computation to that existing mechanism instead of introducing a new one.

Comment on lines +121 to +124
assertTrue(payload.containsKey("datasources"));
assertTrue(payload.containsKey("metadata_profile"));
assertTrue(payload.containsKey("measurement_duration"));
assertEquals("prometheus-1", payload.get("datasource"));
assertEquals(List.of("prometheus-1"), payload.get("datasources"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nitpick: Use BulkSchedulerConstants.DATASOURCES instead of hardcoded string keys in assertions.

Since this field now has a BulkSchedulerConstants.DATASOURCES constant, reference it in these assertions (e.g. payload.containsKey(BulkSchedulerConstants.DATASOURCES)) so the tests track the production contract if the key changes.

Signed-off-by: Saad Khan <saakhan@ibm.com>
@khansaad
khansaad force-pushed the add-multi-ds-support branch from 63aba3e to 3919488 Compare June 18, 2026 15:13
@khansaad

Copy link
Copy Markdown
Author

@sourcery-ai review

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

Hey - I've found 2 issues, and left some high level feedback:

  • The environment variable naming for multi-datasource support is inconsistent: application.yml maps kruize.defaults.datasources to KRUIZE_DATASOURCES, but the overlays set KRUIZE_DEFAULT_DATASOURCES, so those overlays will never affect the new setting—consider standardizing on a single env var name across base and overlays.
  • The new getDefaultDatasourceNames() no longer validates the configured names against cachedDatasources (unlike the previous getDefaultDatasourceName() which fell back to the first available if the configured default was missing); if this fallback behavior is still desired, consider reintroducing validation and fallback for each configured name.
  • In buildBulkPayload, the fallback branch that sets the deprecated datasource field to null when the list is empty is both unexpected and not used by callers; it may be clearer either to omit the key altogether or to throw/log an error instead of sending a null value.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The environment variable naming for multi-datasource support is inconsistent: `application.yml` maps `kruize.defaults.datasources` to `KRUIZE_DATASOURCES`, but the overlays set `KRUIZE_DEFAULT_DATASOURCES`, so those overlays will never affect the new setting—consider standardizing on a single env var name across base and overlays.
- The new `getDefaultDatasourceNames()` no longer validates the configured names against `cachedDatasources` (unlike the previous `getDefaultDatasourceName()` which fell back to the first available if the configured default was missing); if this fallback behavior is still desired, consider reintroducing validation and fallback for each configured name.
- In `buildBulkPayload`, the fallback branch that sets the deprecated `datasource` field to `null` when the list is empty is both unexpected and not used by callers; it may be clearer either to omit the key altogether or to throw/log an error instead of sending a `null` value.

## Individual Comments

### Comment 1
<location path="deployment/overlays/openshift/kustomization.yaml" line_range="24-26" />
<code_context>
+                  # Deprecated - use KRUIZE_DEFAULT_DATASOURCES for multi-datasource support
                   - name: KRUIZE_DEFAULT_DATASOURCE
                     value: "prometheus-1"
+                  # New - comma-separated list of datasources (takes precedence over single datasource)
+                  # Example: "prometheus-1,prometheus-2"
+                  - name: KRUIZE_DEFAULT_DATASOURCES
+                    value: "prometheus-1"
     target:
</code_context>
<issue_to_address>
**issue (bug_risk):** OpenShift overlay repeats the `KRUIZE_DEFAULT_DATASOURCES` vs `KRUIZE_DATASOURCES` mismatch.

This overlay sets `KRUIZE_DEFAULT_DATASOURCES`, but `application.yml` reads `KRUIZE_DATASOURCES`, so the configured list ("thanos-1,cryostat-2") will not affect `kruize.defaults.datasources`. Please align the env var name with `application.yml` to ensure the list is actually used.
</issue_to_address>

### Comment 2
<location path="src/test/java/com/kruize/optimizer/service/BulkSchedulerServiceTest.java" line_range="210-219" />
<code_context>
+     * - Service logs error about missing datasources (ERROR_NO_DATASOURCES_AVAILABLE)
      */
     @Test
     void testScheduledBulkApiCall_NoDatasource() {
         // Arrange
         when(kruizeStateService.isCacheEmpty()).thenReturn(false);
-        when(kruizeStateService.getDefaultDatasourceName()).thenReturn(Optional.empty());
+        when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(Collections.emptyList());

         // Act
         bulkSchedulerService.initialize();
         bulkSchedulerService.scheduledBulkApiCall();

-        // Assert - Should not call bulk API
+        // Assert
+        // - Bulk API not called
         verify(kruizeClient, never()).bulkCreateExperiments(any());
+        // - Jobs counter not incremented
         verify(jobsService, never()).incrementJobsTriggered();
     }

</code_context>
<issue_to_address>
**suggestion (testing):** Consider asserting that the correct error message is logged when no datasources are available.

The Javadoc for `testScheduledBulkApiCall_NoDatasource` documents that `ERROR_NO_DATASOURCES_AVAILABLE` should be logged, but this test only checks that the bulk API is not called and the jobs counter is not incremented. If you have a logging test helper or can inject a logger/appender, please also assert that `ERROR_NO_DATASOURCES_AVAILABLE` is emitted so the test fully verifies the documented behavior and use of the new constant.

Suggested implementation:

```java
import com.kruize.optimizer.client.KruizeClient;
import com.kruize.optimizer.util.MockResponseLoader;
import com.kruize.optimizer.utils.OptimizerConstants.BulkSchedulerConstants;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import nl.altindag.log.LogCaptor;
import org.mockito.Mockito;

import java.io.IOException;
import java.util.Collections;

```

```java
     */
    @Test
    void testScheduledBulkApiCall_NoDatasource() {
        // Arrange
        when(kruizeStateService.isCacheEmpty()).thenReturn(false);
        when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(Collections.emptyList());
        LogCaptor logCaptor = LogCaptor.forClass(BulkSchedulerService.class);

        // Act
        bulkSchedulerService.initialize();
        bulkSchedulerService.scheduledBulkApiCall();

        // Assert
        // - Bulk API not called
        verify(kruizeClient, never()).bulkCreateExperiments(any());
        // - Jobs counter not incremented
        verify(jobsService, never()).incrementJobsTriggered();
        // - Error about missing datasources is logged
        assertTrue(
                logCaptor.getErrorLogs()
                        .stream()
                        .anyMatch(message -> message.contains(BulkSchedulerConstants.ERROR_NO_DATASOURCES_AVAILABLE)),
                "Expected ERROR_NO_DATASOURCES_AVAILABLE to be logged when no datasources are available"
        );
    }

```

1. This change assumes the presence of the `BulkSchedulerService` class in the same package; ensure the test can reference `BulkSchedulerService.class` (add an import if needed: `import com.kruize.optimizer.service.BulkSchedulerService;`).
2. The code uses `LogCaptor` from `nl.altindag.log:log-captor`. If this dependency is not yet present in your test classpath, add it to your build (e.g., in Maven: `test`-scoped dependency `nl.altindag:log-captor`).
3. The test uses `assertTrue`; if this static import is not already present, add `import static org.junit.jupiter.api.Assertions.assertTrue;` to the test file.
4. If your project already has a different logging test helper or pattern, you may prefer to swap `LogCaptor` usage for that helper while keeping the same assertion semantics: verifying that an error log contains `BulkSchedulerConstants.ERROR_NO_DATASOURCES_AVAILABLE`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +24 to +26
# New - comma-separated list of datasources (takes precedence over single datasource)
# Example: "thanos-1,prometheus-1"
- name: KRUIZE_DEFAULT_DATASOURCES

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): OpenShift overlay repeats the KRUIZE_DEFAULT_DATASOURCES vs KRUIZE_DATASOURCES mismatch.

This overlay sets KRUIZE_DEFAULT_DATASOURCES, but application.yml reads KRUIZE_DATASOURCES, so the configured list ("thanos-1,cryostat-2") will not affect kruize.defaults.datasources. Please align the env var name with application.yml to ensure the list is actually used.

Comment on lines 210 to +219
void testScheduledBulkApiCall_NoDatasource() {
// Arrange
when(kruizeStateService.isCacheEmpty()).thenReturn(false);
when(kruizeStateService.getDefaultDatasourceName()).thenReturn(Optional.empty());
when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(Collections.emptyList());

// Act
bulkSchedulerService.initialize();
bulkSchedulerService.scheduledBulkApiCall();

// Assert - Should not call bulk API
// Assert

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Consider asserting that the correct error message is logged when no datasources are available.

The Javadoc for testScheduledBulkApiCall_NoDatasource documents that ERROR_NO_DATASOURCES_AVAILABLE should be logged, but this test only checks that the bulk API is not called and the jobs counter is not incremented. If you have a logging test helper or can inject a logger/appender, please also assert that ERROR_NO_DATASOURCES_AVAILABLE is emitted so the test fully verifies the documented behavior and use of the new constant.

Suggested implementation:

import com.kruize.optimizer.client.KruizeClient;
import com.kruize.optimizer.util.MockResponseLoader;
import com.kruize.optimizer.utils.OptimizerConstants.BulkSchedulerConstants;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import nl.altindag.log.LogCaptor;
import org.mockito.Mockito;

import java.io.IOException;
import java.util.Collections;
     */
    @Test
    void testScheduledBulkApiCall_NoDatasource() {
        // Arrange
        when(kruizeStateService.isCacheEmpty()).thenReturn(false);
        when(kruizeStateService.getDefaultDatasourceNames()).thenReturn(Collections.emptyList());
        LogCaptor logCaptor = LogCaptor.forClass(BulkSchedulerService.class);

        // Act
        bulkSchedulerService.initialize();
        bulkSchedulerService.scheduledBulkApiCall();

        // Assert
        // - Bulk API not called
        verify(kruizeClient, never()).bulkCreateExperiments(any());
        // - Jobs counter not incremented
        verify(jobsService, never()).incrementJobsTriggered();
        // - Error about missing datasources is logged
        assertTrue(
                logCaptor.getErrorLogs()
                        .stream()
                        .anyMatch(message -> message.contains(BulkSchedulerConstants.ERROR_NO_DATASOURCES_AVAILABLE)),
                "Expected ERROR_NO_DATASOURCES_AVAILABLE to be logged when no datasources are available"
        );
    }
  1. This change assumes the presence of the BulkSchedulerService class in the same package; ensure the test can reference BulkSchedulerService.class (add an import if needed: import com.kruize.optimizer.service.BulkSchedulerService;).
  2. The code uses LogCaptor from nl.altindag.log:log-captor. If this dependency is not yet present in your test classpath, add it to your build (e.g., in Maven: test-scoped dependency nl.altindag:log-captor).
  3. The test uses assertTrue; if this static import is not already present, add import static org.junit.jupiter.api.Assertions.assertTrue; to the test file.
  4. If your project already has a different logging test helper or pattern, you may prefer to swap LogCaptor usage for that helper while keeping the same assertion semantics: verifying that an error log contains BulkSchedulerConstants.ERROR_NO_DATASOURCES_AVAILABLE.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: Under Review

Development

Successfully merging this pull request may close these issues.

2 participants