Skip to content

WIP: Add Bulk Profile changes in optimizer - #28

Draft
bharathappali wants to merge 9 commits into
kruize:mvp_demofrom
bharathappali:feat-bulk-profile
Draft

WIP: Add Bulk Profile changes in optimizer#28
bharathappali wants to merge 9 commits into
kruize:mvp_demofrom
bharathappali:feat-bulk-profile

Conversation

@bharathappali

@bharathappali bharathappali commented Jun 19, 2026

Copy link
Copy Markdown
Member

This PR adds the bulk profile support for the optimizer

Summary by Sourcery

Introduce profile-based bulk scheduling alongside the existing fixed-interval mode, driven by bulk profiles fetched from Kruize and managed via per-profile timers.

New Features:

  • Add profile-based scheduling for bulk jobs using individual timers per bulk profile, with a config flag to toggle between profile-driven and legacy fixed-interval modes.
  • Expose new client and REST endpoints to fetch bulk profiles from Kruize and receive profile update webhooks that dynamically adjust scheduling.
  • Introduce bulk profile domain models and services to translate profile configurations into bulk job requests with computed time ranges.

Enhancements:

  • Track and expose bulk job counts per profile for more granular job metrics.
  • Extend optimizer configuration to separate bulk profile enablement from the legacy bulk scheduler settings.

Signed-off-by: bharathappali <abharath@redhat.com>
Signed-off-by: bharathappali <abharath@redhat.com>
Signed-off-by: bharathappali <abharath@redhat.com>
Signed-off-by: bharathappali <abharath@redhat.com>
Signed-off-by: bharathappali <abharath@redhat.com>
Signed-off-by: bharathappali <abharath@redhat.com>
Signed-off-by: bharathappali <abharath@redhat.com>
Signed-off-by: bharathappali <abharath@redhat.com>
@sourcery-ai

sourcery-ai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduces profile-based bulk scheduling by adding bulk profile models and services, a per-profile timer manager that triggers bulk jobs via Kruize, a webhook endpoint for profile updates, and a feature flag to switch between legacy fixed-schedule and new profile-driven behavior, including per-profile job metrics.

Sequence diagram for profile-based bulk scheduling and job execution

sequenceDiagram
    participant BulkSchedulerService
    participant ProfileTimerManager
    participant BulkProfileService
    participant KruizeClient
    participant Scheduler
    participant JobsService

    BulkSchedulerService->>BulkSchedulerService: initialize()
    alt profileBasedSchedulingEnabled
        BulkSchedulerService->>ProfileTimerManager: initializeProfiles()
        ProfileTimerManager->>BulkProfileService: getEnabledProfiles()
        BulkProfileService->>KruizeClient: getBulkProfiles()
        KruizeClient-->>BulkProfileService: bulkProfilesJson
        BulkProfileService-->>ProfileTimerManager: List<BulkProfile>
        loop for each BulkProfile
            ProfileTimerManager->>ProfileTimerManager: scheduleProfile(profile)
            ProfileTimerManager->>Scheduler: scheduleAtFixedRate(executeProfileJob, interval)
        end
    else legacyMode
        BulkSchedulerService->>BulkSchedulerService: scheduledBulkApiCall()
        BulkSchedulerService->>BulkSchedulerService: call legacy bulk API
    end

    rect rgb(230,230,230)
        Scheduler->>ProfileTimerManager: executeProfileJob(profile)
        ProfileTimerManager->>BulkProfileService: convertProfileToBulkJob(profile)
        BulkProfileService-->>ProfileTimerManager: bulkJob
        ProfileTimerManager->>KruizeClient: bulkCreateExperiments(bulkJob)
        KruizeClient-->>ProfileTimerManager: response
        ProfileTimerManager->>JobsService: incrementJobsTriggered(profileName)
    end
Loading

Sequence diagram for bulk profile update webhook handling

sequenceDiagram
    participant Kruize
    participant WebhookResource
    participant BulkSchedulerService
    participant ProfileTimerManager

    Kruize->>WebhookResource: POST /webhook/profile-update BulkProfile
    WebhookResource->>WebhookResource: receiveProfileUpdate(profile)
    alt invalidProfile
        WebhookResource-->>Kruize: 400 Invalid profile update
    else validProfile
        WebhookResource->>BulkSchedulerService: handleProfileUpdate(profile)
        alt profileBasedSchedulingEnabled
            BulkSchedulerService->>ProfileTimerManager: updateProfileTimer(updatedProfile)
            alt updatedProfile.enabled is false
                ProfileTimerManager->>ProfileTimerManager: cancelProfileTimer(profileName)
            else updatedProfile.enabled is true
                ProfileTimerManager->>ProfileTimerManager: updateProfileTimer logic
            end
        else profileBasedSchedulingDisabled
            BulkSchedulerService->>BulkSchedulerService: log ignore update
        end
        BulkSchedulerService-->>WebhookResource: void
        WebhookResource-->>Kruize: 200 OK
    end
Loading

File-Level Changes

Change Details Files
Add feature-flagged profile-based bulk scheduling alongside legacy fixed-schedule mode.
  • Inject ProfileTimerManager and a 'kruize.bulk.profile.enabled' config flag into the bulk scheduler service.
  • On initialization, when profile-based scheduling is enabled, initialize profile timers instead of relying solely on the fixed interval scheduler.
  • Skip the periodic bulk API execution when profile-based scheduling is enabled, keeping the existing fixed-schedule behavior as a fallback path.
  • Expose a handler to process bulk profile update events and delegate timer updates to the timer manager.
src/main/java/com/kruize/optimizer/service/BulkSchedulerService.java
src/main/resources/application.yml
Introduce a ProfileTimerManager to manage per-profile bulk job timers and execution.
  • Maintain a concurrent map of profile-name to ScheduledFuture and a ScheduledExecutorService to run per-profile jobs.
  • Schedule recurring tasks for enabled profiles based on their recommendation scheduling configuration, converting profiles to bulk jobs and calling the bulk create-experiments API.
  • Update or cancel timers when profiles are disabled or their scheduling changes, and shut down all timers on application shutdown.
  • Track jobs triggered per profile via JobsService and expose an active-timer count helper.
src/main/java/com/kruize/optimizer/service/ProfileTimerManager.java
src/main/java/com/kruize/optimizer/service/JobsService.java
Add BulkProfile domain model and supporting services for fetching, parsing, and converting profiles to bulk jobs.
  • Define BulkProfile, ClusterConfig, and RecommendationSettings models that align with Kruize bulk profile JSON structure.
  • Implement BulkProfileService to fetch bulk profiles from Kruize, filter enabled profiles, parse scheduling strings into Durations, and compute time ranges based on measurement duration.
  • Convert a BulkProfile into the bulk job request map expected by the existing bulk API, aggregating datasources, labels, and time range.
  • Add client constants and KruizeClient methods for bulk profile listing and single-profile retrieval.
src/main/java/com/kruize/optimizer/model/kruize/BulkProfile.java
src/main/java/com/kruize/optimizer/model/kruize/ClusterConfig.java
src/main/java/com/kruize/optimizer/model/kruize/RecommendationSettings.java
src/main/java/com/kruize/optimizer/service/BulkProfileService.java
src/main/java/com/kruize/optimizer/utils/OptimizerConstants.java
src/main/java/com/kruize/optimizer/client/KruizeClient.java
Expose a new webhook endpoint to receive bulk profile updates and integrate them with scheduling.
  • Add a POST /webhook/profile-update endpoint that validates incoming BulkProfile payloads and returns 400 on invalid input.
  • Log profile update receipt and delegate to BulkSchedulerService to adjust timers when the feature flag is enabled.
  • Provide error handling that returns a 500 with message when processing fails.
src/main/java/com/kruize/optimizer/resource/WebhookResource.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

@bharathappali bharathappali changed the title Add Bulk Profile changes in optimizer WIP: Add Bulk Profile changes in optimizer Jun 19, 2026
@bharathappali
bharathappali marked this pull request as draft June 19, 2026 05:10

@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 3 issues, and left some high level feedback:

  • In JobsService, incrementJobsTriggered(String profileName) is synchronized while also using a ConcurrentHashMap, which is redundant; consider either removing synchronized and using atomic counters per profile, or using a plain HashMap under synchronized access for consistency with the other counters.
  • ProfileTimerManager currently constructs its own ScheduledThreadPoolExecutor with a hard-coded pool size of 10; consider making the pool size configurable and using a named ThreadFactory so these threads are easier to tune and observe in production.
  • BulkProfileService.getEnabledProfiles() logs and returns an empty list on failure to contact Kruize, which makes errors indistinguishable from 'no profiles'; consider surfacing the failure (e.g., via an exception or a result wrapper) so callers can react differently to transient errors versus a genuinely empty profile set.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In JobsService, `incrementJobsTriggered(String profileName)` is synchronized while also using a `ConcurrentHashMap`, which is redundant; consider either removing `synchronized` and using atomic counters per profile, or using a plain `HashMap` under synchronized access for consistency with the other counters.
- ProfileTimerManager currently constructs its own `ScheduledThreadPoolExecutor` with a hard-coded pool size of 10; consider making the pool size configurable and using a named `ThreadFactory` so these threads are easier to tune and observe in production.
- BulkProfileService.getEnabledProfiles() logs and returns an empty list on failure to contact Kruize, which makes errors indistinguishable from 'no profiles'; consider surfacing the failure (e.g., via an exception or a result wrapper) so callers can react differently to transient errors versus a genuinely empty profile set.

## Individual Comments

### Comment 1
<location path="src/main/java/com/kruize/optimizer/service/ProfileTimerManager.java" line_range="89-98" />
<code_context>
+    public void updateProfileTimer(BulkProfile updatedProfile) {
</code_context>
<issue_to_address>
**issue (bug_risk):** Timer update path always reschedules with immediate execution, making delay inspection/logs misleading.

Both branches of `updateProfileTimer` cancel the current timer and call `scheduleProfile(updatedProfile)`, which always uses an initial delay of `0`. As a result, the remaining delay from `currentTimer.getDelay(...)` is ignored and the “shortened” vs “changed” paths behave the same. Please either simplify by removing the unused delay calculation/logging, or update `scheduleProfile`/this method so the next run actually uses the computed delay.
</issue_to_address>

### Comment 2
<location path="src/main/java/com/kruize/optimizer/service/ProfileTimerManager.java" line_range="74-77" />
<code_context>
+        LOG.infof("Scheduling profile '%s' with interval: %s", profileName, interval);
+
+        // Schedule recurring task
+        ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(
+            () -> executeProfileJob(profile),
+            0,  // Initial delay = 0 (execute immediately)
+            interval.toMillis(),
+            TimeUnit.MILLISECONDS
+        );
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Running blocking HTTP calls inside fixed-rate scheduled tasks can cause timer drift and contention.

This uses `scheduleAtFixedRate` to trigger `executeProfileJob(profile)`, which in turn calls `kruizeClient.bulkCreateExperiments(...)` (likely a blocking HTTP call). With a fixed thread pool of 10, slow or stuck calls can cause overlapping runs for the same profile and eventually exhaust the scheduler threads. Consider switching to `scheduleWithFixedDelay` so each run waits for the previous one to finish, or move the HTTP call to a separate executor so the scheduled job stays lightweight. It would also be good to explicitly handle cases where execution time frequently exceeds the interval.
</issue_to_address>

### Comment 3
<location path="src/main/java/com/kruize/optimizer/service/BulkProfileService.java" line_range="59-68" />
<code_context>
+     *
+     * @return List of enabled bulk profiles
+     */
+    public List<BulkProfile> getEnabledProfiles() {
+        try {
+            String response = kruizeClient.getBulkProfiles();
+            List<BulkProfile> allProfiles = objectMapper.readValue(
+                response, 
+                new TypeReference<List<BulkProfile>>() {}
+            );
+            
+            List<BulkProfile> enabledProfiles = allProfiles.stream()
+                .filter(p -> p.getEnabled() != null && p.getEnabled())
+                .collect(Collectors.toList());
+            
+            LOG.infof("Fetched %d enabled profiles out of %d total profiles", 
+                enabledProfiles.size(), allProfiles.size());
+            
+            return enabledProfiles;
+            
+        } catch (Exception e) {
+            LOG.error("Failed to fetch bulk profiles from Kruize", e);
+            return Collections.emptyList();
+        }
+    }
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Swallowing errors and returning an empty profile list can silently disable profile-based scheduling.

Because any exception from `kruizeClient.getBulkProfiles()` or deserialization is mapped to `Collections.emptyList()`, callers like `initializeProfiles()` interpret this as “no enabled profiles” instead of a fetch failure, hiding real operational issues. Please propagate the failure (e.g., throw, or return a result type that distinguishes “0 profiles” from “fetch failed”) so the scheduler can react appropriately or emit a clearer signal.

Suggested implementation:

```java
    /**
     * Fetch all enabled profiles from Kruize
     *
     * @return List of enabled bulk profiles
     */
    public List<BulkProfile> getEnabledProfiles() {
        String response = kruizeClient.getBulkProfiles();
        List<BulkProfile> allProfiles = objectMapper.readValue(
            response,
            new TypeReference<List<BulkProfile>>() {}
        );

        List<BulkProfile> enabledProfiles = allProfiles.stream()
            .filter(p -> p.getEnabled() != null && p.getEnabled())
            .collect(Collectors.toList());

        LOG.infof("Fetched %d enabled profiles out of %d total profiles",
            enabledProfiles.size(), allProfiles.size());

        return enabledProfiles;
    }

```

Callers such as `initializeProfiles()` should now be prepared for this method to throw exceptions (e.g., IO, HTTP, or deserialization errors) instead of always returning a list. If needed, you can add targeted exception handling at a higher level (e.g., around the scheduler initialization) to log and surface a clearer operational signal (metrics, alerts, or a specific failure state) when fetching profiles fails.
</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 +89 to +98
public void updateProfileTimer(BulkProfile updatedProfile) {
String profileName = updatedProfile.getProfileName();

if (updatedProfile.getEnabled() == null || !updatedProfile.getEnabled()) {
// Profile disabled - cancel timer
LOG.infof("Profile '%s' disabled, canceling timer", profileName);
cancelProfileTimer(profileName);
return;
}

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): Timer update path always reschedules with immediate execution, making delay inspection/logs misleading.

Both branches of updateProfileTimer cancel the current timer and call scheduleProfile(updatedProfile), which always uses an initial delay of 0. As a result, the remaining delay from currentTimer.getDelay(...) is ignored and the “shortened” vs “changed” paths behave the same. Please either simplify by removing the unused delay calculation/logging, or update scheduleProfile/this method so the next run actually uses the computed delay.

Comment on lines +74 to +77
ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(
() -> executeProfileJob(profile),
0, // Initial delay = 0 (execute immediately)
interval.toMillis(),

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): Running blocking HTTP calls inside fixed-rate scheduled tasks can cause timer drift and contention.

This uses scheduleAtFixedRate to trigger executeProfileJob(profile), which in turn calls kruizeClient.bulkCreateExperiments(...) (likely a blocking HTTP call). With a fixed thread pool of 10, slow or stuck calls can cause overlapping runs for the same profile and eventually exhaust the scheduler threads. Consider switching to scheduleWithFixedDelay so each run waits for the previous one to finish, or move the HTTP call to a separate executor so the scheduled job stays lightweight. It would also be good to explicitly handle cases where execution time frequently exceeds the interval.

Comment on lines +59 to +68
public List<BulkProfile> getEnabledProfiles() {
try {
String response = kruizeClient.getBulkProfiles();
List<BulkProfile> allProfiles = objectMapper.readValue(
response,
new TypeReference<List<BulkProfile>>() {}
);

List<BulkProfile> enabledProfiles = allProfiles.stream()
.filter(p -> p.getEnabled() != null && p.getEnabled())

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): Swallowing errors and returning an empty profile list can silently disable profile-based scheduling.

Because any exception from kruizeClient.getBulkProfiles() or deserialization is mapped to Collections.emptyList(), callers like initializeProfiles() interpret this as “no enabled profiles” instead of a fetch failure, hiding real operational issues. Please propagate the failure (e.g., throw, or return a result type that distinguishes “0 profiles” from “fetch failed”) so the scheduler can react appropriately or emit a clearer signal.

Suggested implementation:

    /**
     * Fetch all enabled profiles from Kruize
     *
     * @return List of enabled bulk profiles
     */
    public List<BulkProfile> getEnabledProfiles() {
        String response = kruizeClient.getBulkProfiles();
        List<BulkProfile> allProfiles = objectMapper.readValue(
            response,
            new TypeReference<List<BulkProfile>>() {}
        );

        List<BulkProfile> enabledProfiles = allProfiles.stream()
            .filter(p -> p.getEnabled() != null && p.getEnabled())
            .collect(Collectors.toList());

        LOG.infof("Fetched %d enabled profiles out of %d total profiles",
            enabledProfiles.size(), allProfiles.size());

        return enabledProfiles;
    }

Callers such as initializeProfiles() should now be prepared for this method to throw exceptions (e.g., IO, HTTP, or deserialization errors) instead of always returning a list. If needed, you can add targeted exception handling at a higher level (e.g., around the scheduler initialization) to log and surface a clearer operational signal (metrics, alerts, or a specific failure state) when fetching profiles fails.

Signed-off-by: bharathappali <abharath@redhat.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant