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:
+128
@@ -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) && (
|
||||
|
||||
Reference in New Issue
Block a user