feat(workflow): Pick Record round robin strategy (2/3) (#21900)

## 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)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21900?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-21 22:16:26 +02:00
committed by GitHub
parent a682c8fa62
commit fa6d1394af
5 changed files with 336 additions and 9 deletions
@@ -6,6 +6,9 @@ import { resolveInput } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import {
WorkflowStepExecutorException,
@@ -16,13 +19,20 @@ import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
import { isWorkflowPickRecordAction } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/guards/is-workflow-pick-record-action.guard';
import { type WorkflowPickRecordActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-pick-record-action-input.type';
import {
type WorkflowPickRecordActionInput,
type WorkflowPickRecordStrategy,
} from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-pick-record-action-input.type';
const ROUND_ROBIN_CURSOR_TTL_MS = 1000 * 60 * 60 * 24 * 90;
@Injectable()
export class PickRecordWorkflowAction implements WorkflowAction {
constructor(
private readonly findRecordsService: FindRecordsService,
private readonly workflowExecutionContextService: WorkflowExecutionContextService,
@InjectCacheStorage(CacheStorageNamespace.ModuleWorkflow)
private readonly cacheStorageService: CacheStorageService,
) {}
async execute({
@@ -43,7 +53,7 @@ export class PickRecordWorkflowAction implements WorkflowAction {
);
}
const { objectName, recordIds } = resolveInput(
const { objectName, strategy, recordIds } = resolveInput(
step.settings.input,
context,
) as WorkflowPickRecordActionInput;
@@ -80,9 +90,43 @@ export class PickRecordWorkflowAction implements WorkflowAction {
};
}
const pickedRecord =
candidateRecords[Math.floor(Math.random() * candidateRecords.length)];
const orderedRecords = [...candidateRecords].sort((recordA, recordB) =>
String(recordA.id).localeCompare(String(recordB.id)),
);
return { result: pickedRecord };
const pickedIndex = await this.getPickedIndex({
strategy,
candidateCount: orderedRecords.length,
workspaceId: runInfo.workspaceId,
stepId: currentStepId,
});
return { result: orderedRecords[pickedIndex] };
}
private async getPickedIndex({
strategy,
candidateCount,
workspaceId,
stepId,
}: {
strategy: WorkflowPickRecordStrategy;
candidateCount: number;
workspaceId: string;
stepId: string;
}): Promise<number> {
if (strategy === 'ROUND_ROBIN') {
const cursorKey = `pick-record:round-robin:${workspaceId}:${stepId}`;
const nextCursor = await this.cacheStorageService.incrBy(cursorKey, 1);
await this.cacheStorageService.expire(
cursorKey,
ROUND_ROBIN_CURSOR_TTL_MS,
);
return (nextCursor - 1) % candidateCount;
}
return Math.floor(Math.random() * candidateCount);
}
}
@@ -1,4 +1,4 @@
export type WorkflowPickRecordStrategy = 'RANDOM';
export type WorkflowPickRecordStrategy = 'RANDOM' | 'ROUND_ROBIN';
export type WorkflowPickRecordActionInput = {
objectName: string;