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:
@@ -1,16 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
import { isDefined } from '@/utils';
|
||||
import { baseWorkflowActionSettingsSchema } from './base-workflow-action-settings-schema';
|
||||
|
||||
export const workflowPickRecordStrategySchema = z.enum([
|
||||
'RANDOM',
|
||||
'ROUND_ROBIN',
|
||||
'LOAD_BALANCED',
|
||||
]);
|
||||
|
||||
export const workflowPickRecordActionSettingsSchema =
|
||||
baseWorkflowActionSettingsSchema.extend({
|
||||
input: z.object({
|
||||
objectName: z.string(),
|
||||
strategy: workflowPickRecordStrategySchema,
|
||||
recordIds: z.array(z.string()),
|
||||
}),
|
||||
input: z
|
||||
.object({
|
||||
objectName: z.string(),
|
||||
strategy: workflowPickRecordStrategySchema,
|
||||
recordIds: z.array(z.string()),
|
||||
loadBalance: z
|
||||
.object({
|
||||
objectNameSingular: z.string(),
|
||||
fieldName: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((input, ctx) => {
|
||||
if (
|
||||
input.strategy === 'LOAD_BALANCED' &&
|
||||
!isDefined(input.loadBalance)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['loadBalance'],
|
||||
message: 'loadBalance is required when strategy is LOAD_BALANCED',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
+20
@@ -174,6 +174,26 @@ describe('searchVariableInOutputSchema - record output schema', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PICK_RECORD routing', () => {
|
||||
it('routes a Pick Record output variable through the record schema', () => {
|
||||
const result = searchVariableInOutputSchema({
|
||||
schema: mockRecordSchema,
|
||||
stepType: 'PICK_RECORD',
|
||||
stepName: 'Pick Company',
|
||||
rawVariableName: '{{step1.name}}',
|
||||
isFullRecord: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
variableLabel: 'Company Name',
|
||||
variablePathLabel: 'Pick Company > Company Name',
|
||||
variableType: FieldMetadataType.TEXT,
|
||||
fieldMetadataId: 'company-name-metadata-id',
|
||||
compositeFieldSubFieldName: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle undefined recordOutputSchema', () => {
|
||||
const result = searchVariableThroughRecordOutputSchema({
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ const RECORD_STEP_TYPES = [
|
||||
'UPDATE_RECORD',
|
||||
'DELETE_RECORD',
|
||||
'UPSERT_RECORD',
|
||||
'PICK_RECORD',
|
||||
];
|
||||
|
||||
const isRecordOutputSchemaV2 = (
|
||||
|
||||
Reference in New Issue
Block a user