feat(workflow): Pick Record load balanced strategy (3/3) (#21902)

## Overview

Final PR in the Pick Record stack. Adds the **Load Balanced** strategy:
pick the candidate that currently has the *fewest related records*. This
is the "fair assignment" mode — e.g. assign a new company to the account
owner who currently owns the fewest companies, or route a lead to the
rep with the fewest open opportunities.

**Stacked on #21900** (which is stacked on #21899) — merge in order.
This PR's diff against `main` includes PRs 1 & 2 until they merge.

## What changed

- Widened the `strategy` enum to add `LOAD_BALANCED`, and added an
optional `loadBalance: { objectNameSingular, fieldName }` to the action
input.
- Editor: selecting **Load balanced** reveals a **Balance by** object
picker and a **Count by** field picker (the related object's many-to-one
relation fields).
- Executor: for each candidate, counts records of the chosen related
object whose chosen relation points at that candidate, then selects the
least-loaded one.

## How it works

Given pool = workspace members and config `{ objectNameSingular:
"opportunity", fieldName: "pointOfContact" }`, the executor counts, per
member, the opportunities whose `pointOfContact` is that member, and
picks the member with the lowest count.

## Design decisions & tradeoffs

1. **No persistent state — computed live each run.** Unlike round robin,
load balancing reads current data, so there's no cursor to store.
Correct by construction even under concurrency (each run recomputes
counts); the only caveat is two simultaneous runs can both see the same
"least loaded" candidate before either assignment lands (a small,
self-correcting skew), which is inherent to load-balancing and
acceptable.

2. **Count via per-candidate queries.** One filtered count per candidate
(`{ [relationField]: { id: { eq: candidateId } } }`), run in parallel.
For the realistic pool sizes this targets (a team), this is simple and
clear. A single `group_by` aggregate would scale better for very large
pools — noted as a future optimization, deliberately not done to keep
the logic obvious.

3. **Deterministic tie-break.** Candidates are pre-sorted by id (shared
with round robin), and the first minimum wins — so equal-load ties
resolve deterministically rather than arbitrarily.

4. **`Count by` lists all many-to-one relations of the chosen object**
(not filtered to those targeting the pool object). Keeps the editor
simple; picking an unrelated field just yields zero counts, which is
visibly wrong. Filtering options to relations that target the pool
object is a nice follow-up.

5. **Filter on the counted set** (e.g. only *open* opportunities) is
intentionally out of scope for this first cut — documented as a
follow-up.

## Testing

Added `pick-record-load-balanced-workflow.integration-spec.ts`: creates
two fresh companies (0 related opportunities each), attaches one
opportunity to the second, configures `LOAD_BALANCED` counting
opportunities by `company`, and asserts the step picks the **first**
company (0 < 1). Passes locally alongside the random and round-robin
tests (3 suites / 4 tests). `typecheck` + `lint:diff-with-main` green
for shared/server/front.

## The full stack

1. #21899 — Random (the action + the whole scaffold)
2. #21900 — Round robin (atomic Redis cursor)
3. this — Load balanced

Together these enable round-robin / load-balanced / random **assignment
workflows** in Twenty, composed via the standard variable picker (assign
the chosen record downstream with `{{step.<id>.id}}`).

https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8

---
_Generated by [Claude
Code](https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21902?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-06-22 07:09:15 +02:00
committed by GitHub
parent 82f6597dc3
commit 2abf9c2930
11 changed files with 824 additions and 10 deletions
@@ -10,7 +10,9 @@ import { useDebouncedCallback } from 'use-debounce';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useObjectMetadataSelectHelpers } from '@/object-metadata/hooks/useObjectMetadataSelectHelpers';
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
import { FormMultiRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormMultiRecordPicker';
import { InputHint } from '@/ui/input/components/InputHint';
import { Select } from '@/ui/input/components/Select';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
@@ -50,10 +52,14 @@ type WorkflowEditActionPickRecordProps = {
type PickRecordStrategy =
WorkflowPickRecordAction['settings']['input']['strategy'];
type PickRecordLoadBalance =
WorkflowPickRecordAction['settings']['input']['loadBalance'];
type PickRecordFormData = {
objectNameSingular: string;
strategy: PickRecordStrategy;
recordIds: string[];
loadBalance: PickRecordLoadBalance;
};
export const WorkflowEditActionPickRecord = ({
@@ -74,6 +80,7 @@ export const WorkflowEditActionPickRecord = ({
objectNameSingular: action.settings.input.objectName,
strategy: action.settings.input.strategy,
recordIds: action.settings.input.recordIds,
loadBalance: action.settings.input.loadBalance,
}));
const isFormDisabled = actionOptions.readonly ?? false;
@@ -81,11 +88,45 @@ export const WorkflowEditActionPickRecord = ({
const strategyOptions: SelectOption<PickRecordStrategy>[] = [
{ label: t`Random`, value: 'RANDOM' },
{ label: t`Round robin`, value: 'ROUND_ROBIN' },
{ label: t`Load balanced`, value: 'LOAD_BALANCED' },
];
const loadBalanceObjectDropdownId = `workflow-edit-action-pick-record-load-balance-object-${action.id}`;
const loadBalanceObjectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === formData.loadBalance?.objectNameSingular,
);
const loadBalanceObjectOption = loadBalanceObjectMetadataItem
? {
label: loadBalanceObjectMetadataItem.labelPlural,
value: loadBalanceObjectMetadataItem.nameSingular,
...getSelectIconPropsFromObjectMetadataItem(
loadBalanceObjectMetadataItem,
),
}
: { label: i18n._(defaultSelectedOptionMessage), value: '' };
const loadBalanceFieldOptions: SelectOption<string>[] = (
loadBalanceObjectMetadataItem?.fields ?? []
)
.filter(
(field) =>
isManyToOneRelationField(field) &&
field.relation?.targetObjectMetadata?.nameSingular ===
formData.objectNameSingular,
)
.map((field) => ({ label: field.label, value: field.name }));
const hasLoadBalanceFieldOptions = loadBalanceFieldOptions.length > 0;
const selectedObjectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === formData.objectNameSingular,
);
const loadBalancePoolObjectLabel =
selectedObjectMetadataItem?.labelSingular ?? t`the selected object`;
const selectedOption = selectedObjectMetadataItem
? {
label: selectedObjectMetadataItem.labelPlural,
@@ -108,6 +149,10 @@ export const WorkflowEditActionPickRecord = ({
objectName: updatedFormData.objectNameSingular,
strategy: updatedFormData.strategy,
recordIds: updatedFormData.recordIds,
loadBalance:
updatedFormData.strategy === 'LOAD_BALANCED'
? updatedFormData.loadBalance
: undefined,
},
},
});
@@ -130,6 +175,9 @@ export const WorkflowEditActionPickRecord = ({
...formData,
objectNameSingular: value,
recordIds: [],
loadBalance: isDefined(formData.loadBalance)
? { ...formData.loadBalance, fieldName: '' }
: undefined,
};
setFormData(newFormData);
@@ -165,6 +213,35 @@ export const WorkflowEditActionPickRecord = ({
saveAction(newFormData);
};
const handleLoadBalanceObjectChange = (objectNameSingular: string) => {
if (isFormDisabled === true) {
return;
}
const newFormData: PickRecordFormData = {
...formData,
loadBalance: { objectNameSingular, fieldName: '' },
};
setFormData(newFormData);
saveAction(newFormData);
closeDropdown(loadBalanceObjectDropdownId);
};
const handleLoadBalanceFieldChange = (fieldName: string) => {
if (isFormDisabled === true || !isDefined(formData.loadBalance)) {
return;
}
const newFormData: PickRecordFormData = {
...formData,
loadBalance: { ...formData.loadBalance, fieldName },
};
setFormData(newFormData);
saveAction(newFormData);
};
return (
<>
<WorkflowStepBody>
@@ -200,6 +277,57 @@ export const WorkflowEditActionPickRecord = ({
onChange={handleStrategyChange}
/>
{formData.strategy === 'LOAD_BALANCED' && (
<>
<StyledObjectSelectContainer>
<StyledLabel>{t`Balance by`}</StyledLabel>
<Dropdown
dropdownId={loadBalanceObjectDropdownId}
dropdownPlacement="bottom-start"
clickableComponent={
<SelectControl
isDisabled={isFormDisabled}
selectedOption={loadBalanceObjectOption}
/>
}
dropdownComponents={
!isFormDisabled && (
<WorkflowObjectDropdownContent
onOptionClick={handleLoadBalanceObjectChange}
/>
)
}
dropdownOffset={{ y: 4 }}
/>
</StyledObjectSelectContainer>
{isDefined(loadBalanceObjectMetadataItem) && (
<StyledObjectSelectContainer>
<Select
dropdownId={`workflow-edit-action-pick-record-load-balance-field-${action.id}`}
label={t`Count by`}
fullWidth
disabled={isFormDisabled || !hasLoadBalanceFieldOptions}
emptyOption={{
label: hasLoadBalanceFieldOptions
? i18n._(defaultSelectedOptionMessage)
: t`No relation to count by`,
value: '',
}}
value={formData.loadBalance?.fieldName ?? ''}
options={loadBalanceFieldOptions}
onChange={handleLoadBalanceFieldChange}
/>
{!hasLoadBalanceFieldOptions && (
<InputHint>
{t`${loadBalanceObjectMetadataItem.labelPlural} have no relation to ${loadBalancePoolObjectLabel}. Pick a different object to balance by.`}
</InputHint>
)}
</StyledObjectSelectContainer>
)}
</>
)}
<HorizontalSeparator noMargin />
{isDefined(selectedObjectMetadataItem) && (