Skip to content

perf(server): index relation rows when hydrating nested relations (O(n*m) -> O(n+m)) - #23232

Closed
irontaek wants to merge 2 commits into
twentyhq:mainfrom
irontaek:perf/nested-relations-index
Closed

irontaek wants to merge 2 commits into
twentyhq:mainfrom
irontaek:perf/nested-relations-index

Conversation

@irontaek

@irontaek irontaek commented Jul 24, 2026 •

Copy link
Copy Markdown

What

processNestedRelations hydrates relations onto parent records, and for every parent record it re-scans the entire relationResults array:

parentRecords.forEach((item) => {
  if (relationType === RelationType.ONE_TO_MANY) {
    item[sourceFieldName] = relationResults.filter((rel) => rel[joinField] === item.id);
  } else {
    const matchedRelation = relationResults.find((rel) => rel.id === item[joinColumnName]);
    ...
  }
});

That is O(n*m) — parent records × relation rows — on every query that selects a relation. A page of 60 records with 500 relation rows is ~30k comparisons, and it grows with both page size and relation cardinality.

How

Index relationResults once before the loop, then look up in O(1):

  • ONE_TO_MANY — group by joinField into Map<key, rel[]>, then map.get(item.id) ?? [] (the ?? [] preserves the empty-array result of .filter).
  • otherwise — index by id, first occurrence wins so the behaviour matches Array.prototype.find (a plain new Map(arr.map(...)) would keep the last one).

Parent ids are unique, so each ONE_TO_MANY bucket belongs to exactly one parent — no array is shared between records.

No behaviour change; only the lookup strategy.

Found with cleanscore (flags linear scans of an outer array inside a loop) and verified by hand. Description drafted with LLM assistance.

Review in cubic

irontaek added 2 commits July 24, 2026 10:00
…t (O(n*m) -> O(n+m))

Each upsert loop re-scanned the existing fields/filter groups/filters/sorts with
.find() for every input item. Build the lookup maps once (first occurrence wins,
matching the previous .find() semantics) and look up in O(1).
…n*m) -> O(n+m))

processNestedRelations re-scanned the whole relationResults array for every parent
record — .filter() for ONE_TO_MANY and .find() otherwise. Index the rows once
(grouped by join field, or by id with first-occurrence-wins to match .find) and
look up in O(1).
@twenty-ci-bot-public

Copy link
Copy Markdown

👋 Thanks for contributing to Twenty! We're excited to have you on board.

Your PR has been set to draft while you work on it. Once you're done, mark it as Ready for review and our automated checks will run.

By submitting your Pull Request, you acknowledge that you agree with the terms of our Contributor License Agreement.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Re-trigger cubic

@github-actions

Copy link
Copy Markdown
Contributor

Welcome!

Hello there, congrats on your first PR! We're excited to have you contributing to this project.
By submitting your Pull Request, you acknowledge that you agree with the terms of our Contributor License Agreement.

Generated by 🚫 dangerJS against e1fb3da

@etiennejouan etiennejouan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @yoo-minho, thank you for your contribution. I think the performance gains here are not substantial enough to justify merging this PR. I've left you some comments about Twenty patterns and our codebase style.

Please feel free to tackle any open issues, I would be happy to review and merge !

Comment on lines +530 to +531
// Index the relation rows once instead of re-scanning them for every parent
// record (O(n*m) -> O(n+m)). Parent ids are unique, so each bucket belongs to

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No comment if avoidable in twenty codebase

// Index the relation rows once instead of re-scanning them for every parent
// record (O(n*m) -> O(n+m)). Parent ids are unique, so each bucket belongs to
// exactly one parent.
// oxlint-disable-next-line typescript/no-explicit-any

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exceptions like this are real code smell. It's really rare and you should always wonder if there is no other way

for (const rel of relationResults) {
const bucket = relationsByJoinField.get(rel[joinField]);

if (bucket) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isDefined(bucket)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants