From fa6d1394af95e7f541aa0f071212deec4921a03b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sun, 21 Jun 2026 22:16:26 +0200 Subject: [PATCH] feat(workflow): Pick Record round robin strategy (2/3) (#21900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview Second PR in the Pick Record stack. Adds a **Round Robin** selection strategy alongside Random, so an assignment workflow can distribute records *evenly* across a candidate pool (e.g. rotate company ownership across a set of workspace members) rather than just randomly. **Stacked on #21899** — review/merge that one first. This PR's diff against `main` includes PR 1's commits until #21899 merges. ## What changed - Widened the `strategy` enum (`RANDOM` → `RANDOM | ROUND_ROBIN`) in the shared schema and the server input type. - Editor now shows a **Strategy** selector (Random / Round robin). The candidate-pool label changed from "Pick at random from" to the neutral "Pick from" since random is no longer the only mode. - Executor implements round robin. ## Design decisions & tradeoffs 1. **State store: Redis `incrBy` (atomic), keyed `pick-record:round-robin:{workspaceId}:{stepId}`.** Round robin needs a persistent cursor, and workflow runs are **not** serialized — two runs can execute the same step concurrently — so the increment must be atomic. `CacheStorageService.incrBy` (workflow cache namespace) is a single atomic Redis op, needs no schema change, and is already injectable. Index = `(cursor - 1) % poolSize`. **Tradeoff — durability:** a Redis flush/eviction resets the cursor, which restarts the cycle from an offset. That causes a one-time *fairness drift*, never a *correctness* bug (no double-assignment, since each increment is atomic). If strict durability is ever required, the cursor can move to a Postgres counter table with `INSERT … ON CONFLICT … DO UPDATE SET cursor = cursor + 1 RETURNING cursor` (atomic + durable) — deliberately **not** done here to avoid a migration for what is, in practice, an acceptable reset. 2. **Deterministic pool ordering.** The resolved pool is sorted by `id` before the cursor is applied, so position→record mapping is stable run-to-run regardless of fetch order. Without this, round robin wouldn't reliably cycle. 3. **Cursor key uses `stepId`.** Stable across runs of a published version. Republishing a version may mint new step ids, which resets the cursor — acceptable and documented here. 4. **Slot-on-increment.** The cursor increments when the step runs (reserving a position); if a later step in the run fails, that position is effectively skipped. Minor, acceptable unfairness — flagged rather than adding cross-step compensation. ## Testing Added `pick-record-round-robin-workflow.integration-spec.ts`: builds a workflow with a 3-record pool and `ROUND_ROBIN`, runs it 4 times sequentially, and asserts the picks are exactly `[p0, p1, p2, p0]` (full cycle + wraparound) against the deterministically-ordered pool. Passes locally alongside PR 1's random test (2 suites / 3 tests). `typecheck` + `lint:diff-with-main` green for shared/server/front. ## Follow-up - PR 3: `LOAD_BALANCED` (fewest related records wins). https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8 --- _Generated by [Claude Code](https://claude.ai/code/session_01MuPWZsqf2bmQSevk6fRbX8)_ Review in cubic --- .../WorkflowEditActionPickRecord.tsx | 41 ++- .../pick-record.workflow-action.ts | 54 +++- .../workflow-pick-record-action-input.type.ts | 2 +- ...d-round-robin-workflow.integration-spec.ts | 243 ++++++++++++++++++ .../pick-record-action-settings-schema.ts | 5 +- 5 files changed, 336 insertions(+), 9 deletions(-) create mode 100644 packages/twenty-server/test/integration/graphql/suites/workflow/pick-record-round-robin-workflow.integration-spec.ts diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/pick-record-action/components/WorkflowEditActionPickRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/pick-record-action/components/WorkflowEditActionPickRecord.tsx index c60ab8c040..8036810155 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/pick-record-action/components/WorkflowEditActionPickRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/pick-record-action/components/WorkflowEditActionPickRecord.tsx @@ -4,12 +4,14 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react/macro'; import { useEffect, useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; +import { type SelectOption } from 'twenty-ui/input'; import { HorizontalSeparator } from 'twenty-ui/layout'; import { useDebouncedCallback } from 'use-debounce'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useObjectMetadataSelectHelpers } from '@/object-metadata/hooks/useObjectMetadataSelectHelpers'; import { FormMultiRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormMultiRecordPicker'; +import { Select } from '@/ui/input/components/Select'; import { SelectControl } from '@/ui/input/components/SelectControl'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; @@ -45,8 +47,12 @@ type WorkflowEditActionPickRecordProps = { }; }; +type PickRecordStrategy = + WorkflowPickRecordAction['settings']['input']['strategy']; + type PickRecordFormData = { objectNameSingular: string; + strategy: PickRecordStrategy; recordIds: string[]; }; @@ -66,11 +72,17 @@ export const WorkflowEditActionPickRecord = ({ const [formData, setFormData] = useState(() => ({ objectNameSingular: action.settings.input.objectName, + strategy: action.settings.input.strategy, recordIds: action.settings.input.recordIds, })); const isFormDisabled = actionOptions.readonly ?? false; + const strategyOptions: SelectOption[] = [ + { label: t`Random`, value: 'RANDOM' }, + { label: t`Round robin`, value: 'ROUND_ROBIN' }, + ]; + const selectedObjectMetadataItem = objectMetadataItems.find( (item) => item.nameSingular === formData.objectNameSingular, ); @@ -94,7 +106,7 @@ export const WorkflowEditActionPickRecord = ({ ...action.settings, input: { objectName: updatedFormData.objectNameSingular, - strategy: action.settings.input.strategy, + strategy: updatedFormData.strategy, recordIds: updatedFormData.recordIds, }, }, @@ -115,6 +127,7 @@ export const WorkflowEditActionPickRecord = ({ } const newFormData: PickRecordFormData = { + ...formData, objectNameSingular: value, recordIds: [], }; @@ -124,6 +137,20 @@ export const WorkflowEditActionPickRecord = ({ closeDropdown(dropdownId); }; + const handleStrategyChange = (strategy: PickRecordStrategy) => { + if (isFormDisabled === true) { + return; + } + + const newFormData: PickRecordFormData = { + ...formData, + strategy, + }; + + setFormData(newFormData); + saveAction(newFormData); + }; + const handleRecordIdsChange = (recordIds: string[]) => { if (isFormDisabled === true) { return; @@ -163,12 +190,22 @@ export const WorkflowEditActionPickRecord = ({ /> +