Skip to content

perf(contacts): serve the contact field list from Redis - #581

Merged
driaug merged 1 commit into
nextfrom
perf/contact-fields-cache
Oct 28, 2026
Merged

driaug merged 1 commit into
nextfrom
perf/contact-fields-cache

Conversation

@driaug

@driaug driaug commented Oct 28, 2026

Copy link
Copy Markdown
Member

Description

Closes #487. Replaces #490, whose analysis and single-pass query this builds on.

GET /contacts/fields scanned every contact in the project on each request and re-read the table once per custom field, so the segment builder, workflow condition editor and template editor were slow to open on large projects.

  • Field names are registered on write. ContactService.create, update, upsert and the workflow "update contact" step add the Contact.data keys they store to a per-project sorted set in Redis (one Lua call per write). A new field appears in the pickers immediately.
  • Coverage and type are measured in the background. A new contact-fields BullMQ job runs one pass over the project's contacts and stores the result. When it finishes, it also removes keys no contact carries any more. A key first written while a scan is running is kept.
  • The endpoint only reads Redis. It queues a scan when the measurements are missing, older than an hour, or lack a field.
  • It throttles itself, with no configuration. Scans triggered by writes are debounced by 10s, so an import collapses into one scan. A project is scanned at most once every 5 minutes. Idle projects expire from Redis after 30 days.
  • Deleting a field removes it from the list immediately.
  • The segment builder loads its options through SWR, so reopening it no longer refetches everything.

Behaviour changes

  • A field that has not been measured yet reports coverage: null. The editor menus and template lint skip it, and the data settings table shows "—".
  • Until a project's first scan completes, the response has pending: true and lists only the standard fields plus fields written since. The frontend polls every 3s while pending.
  • A stored JSON null no longer counts toward coverage, and a field holding mixed types reports as string.

Type of Change

  • feat: New feature (MINOR version bump)
  • fix: Bug fix (PATCH version bump)
  • feat!: Breaking change - new feature (MAJOR version bump)
  • fix!: Breaking change - bug fix (MAJOR version bump)
  • docs: Documentation update (no version bump)
  • chore: Maintenance/dependencies (no version bump)
  • refactor: Code refactoring (no version bump)
  • test: Adding tests (no version bump)
  • perf: Performance improvement (PATCH version bump)

Testing

  • New ContactFieldService.test.ts (9 tests): coverage and type, a new field offered before it is measured, no scan for known fields, removal of unused keys, a key written during a scan, field deletion, project isolation.
  • Workflows.test.ts now creates its contact through ContactService, since custom fields are registered by writes. Three test files that mock Redis got a defineCommand stub.
  • Checked end to end against real BullMQ with a throwaway test: the worker ran the first scan, then pushed a follow-up scan back to the 5-minute mark.
  • Full suite: 1652 passing. tsc --noEmit and ESLint clean for api and web.

Checklist

  • PR title follows conventional commits format
  • Code builds successfully
  • Tests pass locally
  • Documentation updated (if needed)

🤖 Generated with Claude Code

GET /contacts/fields scanned every contact in the project on each request,
re-reading the table once per custom field. At millions of contacts the
segment builder took a minute to open (#487).

The field names are now registered on write: every path that stores
Contact.data adds its keys to a per-project sorted set in Redis, so a new
field is offered immediately. Coverage and type are measured by a
background contact-fields job in a single pass over the project's contacts,
which also prunes keys no contact carries any more. The endpoint only reads
Redis and queues a scan when the measurements are missing, older than an
hour, or lack a field. Write-triggered scans are debounced by 10 seconds and
a project is scanned at most every 5 minutes.

A field not measured yet reports coverage as null. A stored JSON null no
longer counts toward coverage, and a field holding mixed types reports as
string. The segment builder loads its options through SWR.

Based on the analysis and single-pass query from #490.

Co-authored-by: Pau Sanchez <contact@pausanchez.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@driaug
driaug merged commit 627f7e9 into next Oct 28, 2026
5 checks passed
@driaug
driaug deleted the perf/contact-fields-cache branch October 28, 2026 18:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Segment filter editor in the UI is really slow with several million contacts

1 participant