Introducing Randomized Round-Robin Balancing for SugarBPM Processes

Author: Yuri Gee

Date: 19 Aug 2025

5 minute read time

As you know, SugarCRM supports Round-Robin Balancing in SugarBPM, which can be configured to operate sequentially and/or based on user availability. This balancing or routing mechanism can be applied across a wide range of scenarios—such as assigning tasks, emails, cases, or distributing opportunities and BPM forms across teams. Whether triggered by the creation of new records, REST API calls, or any process implemented using BPM, routing plays a central role.

In this article, I introduce a simple enhancement: adding randomness to the standard load balancing mechanism. This feature is configured directly within the BPM designer, simply by using a team name that begins with RR_. If the team name does not start with RR_, the system will default to standard load balancing.

When the team name begins with RR_ (e.g., RR_Team), SugarBPM retrieves the members of that team and applies a strong randomization function to distribute requests evenly. Each user receives tasks with equal probability—e.g., 50% for 2 users, 33.(3)% for 3 users, 25% for 4 users, and so on. Statistical analysis using mean and deviation can show that as the number of assignments and users increases, the probability of task distribution deviating more than 20% from the expected average drops significantly already at around 50 tasks.

BPM with Randomized Round-Robin Load Balancing

Additionally, by rotating members of the RR_Team, you can dynamically control which resources are available at any given time, ensuring that routing adapts to real-time availability.

Example: 50 Tasks Distributed Across 3 Users

This is an actual data from the SugarCRM log in a stock instance involving three users—Chris, Jim, and Administrator—utilizing the Randomized Round Robin mechanism to distribute 51 tasks. The allocation ultimately resulted in 18 tasks for Administrator, 16 for Chris, and 17 for Jim.

Outcome tasks distribution for 50 tasks and 3 users

Code Implementation

Added to either ./custom/modules/pmse_Inbox/engine/PMSEElements/PMSERoundRobin.php or ./modules/pmse_Inbox/engine/PMSEElements/PMSERoundRobin.phpQueries (please review the 'TO_NOTE' annotations for clarification):

<?php
/*
 * from original modules/pmse_Inbox/engine/PMSEElements/PMSERoundRobin.php
 * can be copied in the same or custom folder : ./custom/modules/pmse_Inbox/engine/PMSEElements/PMSERoundRobin.php
 */
class PMSERoundRobin extends PMSEScriptTask
{
    public function run($flowData, $bean = null, $externalAction = '', $arguments = [])
    {
        $historyData = null;
        switch ($externalAction) {
            case 'RESUME_EXECUTION':
                $flowAction = 'UPDATE';
                break;
            default:
                $flowAction = 'CREATE';
                break;
        }

        $bpmnElement = $this->retrieveDefinitionData($flowData['bpmn_id']);
        $act_assign_team = $bpmnElement['act_assign_team'];

        $teamBean = $this->retrieveTeamData($act_assign_team);

        $act_assignment_method = $bpmnElement['act_assignment_method'];
        if (isset($bean->team_id) && isset($teamBean->id) && ($teamBean->id == $act_assign_team)) {
            $beanChanged = false;
            if (strtolower($act_assignment_method) == 'balanced') {
                //TO_NOTE: comenting original line and replacing it with the next line
                //$nextUser = $this->userAssignmentHandler->getNextAvailableUser($bpmnElement, $bean, $flowData); 

                $nextUser = !empty($bpmnElement['act_set_by_avl'])
                    ? $this->userAssignmentHandler->getNextAvailableUser($bpmnElement, $bean, $flowData)
                    : $this->getNextUserUsingRandomizedRoundRobin($bpmnElement['id'], $bpmnElement, $bean, $flowData);     

                if (isset($bpmnElement['act_update_record_owner']) && $bpmnElement['act_update_record_owner'] == 1) {
                    $historyData = $this->retrieveHistoryData($flowData['cas_sugar_module']);
                    $historyData->savePreData('assigned_user_id', $bean->assigned_user_id);
                    if ($nextUser !== $bean->assigned_user_id) {
                        $bean->assigned_user_id = $nextUser;
                        $beanChanged = true;
                    }
                    $historyData->savePostData('assigned_user_id', $nextUser);

                    /** @var TeamSet $teamSetBean */
                    $teamSetBean = BeanFactory::newBean('TeamSets');
                    $teams = $teamSetBean->getTeams($bean->team_set_id);
                    if (!array_key_exists($act_assign_team, $teams)) {
                        $teamSet = array_keys($teams);
                        $teamSet[] = $act_assign_team;
                        $teamSetId = $teamSetBean->addTeams($teamSet);
                        $historyData->savePreData('team_set_id', $bean->team_set_id);
                        if ($teamSetId !== $bean->team_set_id) {
                            $bean->team_set_id = $teamSetId;
                            $beanChanged = true;
                        }
                        $historyData->savePostData('team_set_id', $teamSetId);
                    }
                }
                $flowData['cas_user_id'] = $nextUser;
            }
            if ($beanChanged) {
                PMSEEngineUtils::saveAssociatedBean($bean);
            }

            $params = [];
            $params['cas_id'] = $flowData['cas_id'];
            $params['cas_index'] = $flowData['cas_index'];
            $params['act_id'] = $bpmnElement['id'];
            $params['pro_id'] = $bpmnElement['pro_id'];
            $params['user_id'] = $this->getCurrentUser()->id;
            $params['frm_action'] = 'Event Team Assign';
            $params['frm_comment'] = 'Team Assign Applied';
            if (isset($bpmnElement['act_update_record_owner']) && $bpmnElement['act_update_record_owner'] == 1) {
                $params['log_data'] = $historyData->getLog();
            }
            $this->caseFlowHandler->saveFormAction($params);
        }
        return $this->prepareResponse($flowData, 'ROUTE', $flowAction);
    }

    // TO_NOTE: Additional function to enabled RR - randomized Round Robin
    private function getNextUserUsingRandomizedRoundRobin($act_id, $bpmnElement, $bean, $flowData)
    {
        //getting record from bpm_activity_definition  
        $beanBpmActivity = BeanFactory::getBean('pmse_BpmActivityDefinition', $act_id); 

        $assign_team = $beanBpmActivity->act_assign_team;
        $last_assigned = $beanBpmActivity->act_last_user_assigned;
            
        if (empty($assign_team) || (substr($this->getTeamNameById($assign_team), 0, 3) !== 'RR_') ) {
            return $this->userAssignmentHandler->getNextAvailableUser($bpmnElement, $bean, $flowData);
        }
                    
        $q = new SugarQuery();
        $q->select(['id']);
        $q->from(BeanFactory::newBean('Users'));
        
        $q->joinTable('team_memberships', ['alias' => 'membership'])->on()
            ->equals('membership.team_id', $assign_team)
            ->equalsField('membership.user_id', 'id')
            ->equals('membership.explicit_assign', 1)
            ->equals('membership.deleted', 0);
        
        $q->where()
            ->equals('status', 'Active')
            ->equals('employee_status', 'Active');
        $q->orderBy('id', 'ASC');
        
        $results = $q->execute();

        $nextUserId = !empty($results) ? $results[random_int(0, count($results) - 1)]['id'] : '';

        //TO_NOTE: Log next user chosen in randomized Load balancer (optional)
        $GLOBALS['log']->fatal("Next user ID: {$nextUserId} in activity {$act_id}");
        
        //updating last user selected
        $beanBpmActivity->act_last_user_assigned = $nextUserId;
        $beanBpmActivity->save();
        
        return $nextUserId;
    }

    // TO_NOTE: Additional function to resolve team name
    private function getTeamNameById($teamId) {

        $sq = new SugarQuery();
        $sq->select(['name']);
        $sq->from(BeanFactory::newBean('Teams'));
        $sq->where()->equals('id', $teamId);
        $results = $sq->execute();
      
        return !empty($results) ? $results[0]['name'] : '';
    }
}

Additional Thoughts

Efficient routing mechanisms—whether server-side or user-based can be challenging to implement, especially when it comes to correctly weighting, scoring, and calibrating each task. For example, some tasks may be complex but quick, requiring creativity, while others may be algorithmically intensive and time-consuming. Once a complex task is solved, it may become routine and lose its complexity. While using AI for initial complexity assessment and ongoing calibration is a promising direction, it still remains difficult to execute effectively.

When task complexity is not clearly defined or well understood, and existing tools are not equipped to handle it, introducing randomization and its variants into the routing strategy becomes a practical alternative. Randomized balancing can yield better results than sequential methods in many cases, helping to avoid synchronized overload and distribute tasks more evenly through probabilistic methods. Though it may still require occasional calibration, this approach offers a more flexible and resilient automated solution when precision is difficult to achieve, ultimately enhancing task execution quality over time in a quantifiable manner. The method can also be adapted to perform randomized balancing based on weights stored within Sugar records, whenever needed.

The code is provided as-is and should be tested and potentially refined before being used in a production environment. It logs each user assignment to Sugar Log, allowing you to run it in a pilot phase to observe how the algorithm performs. Your feedback is always appreciated!