Skip to content

perf(ingestion): optimize group batch store writes and reads - #69946

Merged
jose-sequeira merged 11 commits into
masterfrom
jose-sequeira/group-batch-store-optimizations
Jul 14, 2026
Merged

perf(ingestion): optimize group batch store writes and reads#69946
jose-sequeira merged 11 commits into
masterfrom
jose-sequeira/group-batch-store-optimizations

Conversation

@jose-sequeira

Copy link
Copy Markdown
Contributor

Problem

The group batch store has been showing up as a production slowdown in ingestion flushes. It lagged the person store by a generation:

  • Every cache miss was a single-row SELECT on the Postgres primary, one per group key per batch (no batched prefetch).
  • Flush wrote each dirty group individually with a version-CAS UPDATE, so hot group keys (one big customer's organization receiving $groupidentify on every pod) caused conflict-retry storms: each lost race cost a failed update, a refetch, and a retry at a fixed 50ms interval with no jitter.
  • Any object-valued group property was compared by reference, so groups with nested properties were marked dirty on every event, manufacturing no-op writes, version bumps, and cross-pod conflicts.
  • New groups were created inline per event inside a transaction, and ClickHouse produces were awaited inside flush, both inflating the blocking batch-commit path.

Changes

Active immediately:

  • Use deep equality when diffing group properties, so identical nested payloads stop dirtying groups.
  • Skip the conflict retry when the refetched row already contains the update (the common hot-key case), and sync the cached version after successful writes so persistent cache entries stop manufacturing self-conflicts.
  • Create new groups with a single ON CONFLICT DO NOTHING insert instead of a wrapping transaction.
  • Return ClickHouse messages from flush as side effects (produced by the flush step like person messages) instead of awaiting delivery per group inside flush. Group flush results get their own GroupFlushResult type and BatchWritingStore is now generic, keeping person and group types separate.
  • Jitter the optimistic-retry interval so conflicting pods stop retrying in lockstep.

Behind flags (default off):

  • GROUP_BATCH_WRITING_USE_BATCH_UPDATES: flush writes all dirty groups in one UPDATE ... FROM UNNEST with server-side jsonb merge of per-group deltas. No version assertion, no conflict retries, and concurrent pods cannot clobber each other's keys. Inputs are sorted by row identity for deterministic lock order; missing rows and statement failures fall back to the individual path.
  • GROUPS_PREFETCH_ENABLED: a prefetchGroupsStep warms the group cache with one batched fetchGroupsByKeys query per chunk of $groupidentify events, mirroring the persons prefetch including the released-batch guard. fetchGroupsByKeys now returns created_at and version (Postgres and personhog) so prefetched entries are complete cache entries.

Note

Rollout: both flags ship off. The unflagged changes alone should remove most hot-key conflict storms. Suggested order: observe, then enable batch updates (watch posthog_group lock waits and batch_store_flush_latency_seconds{store="group"}), then prefetch. Set the env vars on the ingestion API server deployment as well as the consumers.

How did you test this code?

Automated only, no manual testing:

  • batch-writing-group-store.test.ts (27 passing): new cases cover the regressions each change guards - deep-equal no-op detection (a revert to reference comparison re-dirties groups on every event), the conflict short-circuit (a revert reintroduces retry storms), the batched flush path (delta contents, merged-row ClickHouse message, cache sync, both fallbacks), and prefetch serving upserts without single-row fetches. Existing tests updated for the new create/flush behavior; the suite also got ~100x faster because it no longer burns real retry backoffs.
  • postgres-group-repository.integration.test.ts: new updateGroupsBatch case against real Postgres verifies the SQL merge semantics (jsonb ||, LEAST created_at backdating, version bump, missing rows omitted) that a unit test cannot.
  • flush-batch-stores-step.test.ts: group results are produced as side effects and an oversized group message emits a group-specific warning instead of failing the batch.
  • prefetchGroupsStep.test.ts: only $groupidentify events with known group types are prefetched, grouped per batch store.
  • Full typecheck plus the adjacent suites (person store, personhog group repository, process-groups step, ClickHouse repository): 175 tests passing.

Automatic notifications

  • Publish to changelog?
  • Alert Sales and Marketing teams?

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Authored with Claude Code (Fable 5). Started as a review comparing the group and person batch stores to explain production flush slowdowns, then implemented the findings on José's direction. Skills invoked: /writing-tests.

Decisions along the way:

  • Kept FlushResult person-only per review feedback: instead of widening the persons type, BatchWritingStore became generic and groups got their own GroupFlushResult.
  • Extended fetchGroupsByKeys (including the personhog gRPC mapping) rather than adding a Postgres-only prefetch method, after validating in the charts repo that personhog is not enabled on the analytics ingestion consumers today but is live on CDP and error-tracking apps sharing this repository code.
  • For batch-update lock ordering, considered a SELECT ... FOR UPDATE ORDER BY CTE (deterministic but ~2x index probes per row) and chose input sorting instead: free, removes the practical deadlock risk at group-flush sizes, and the individual-write fallback covers the residue. Persons' updatePersonsBatch has the same unsorted shape at higher volume; a follow-up there is worth a separate PR.

Object and array values in $group_set arrive as fresh JSON parses, so the
previous loose reference comparison marked any group with a nested property
as changed on every event. That caused constant no-op Postgres writes,
version bumps, ClickHouse produces, and cross-pod optimistic-update
conflicts for hot group keys.
The group store lagged the person store and showed up as a production
flush bottleneck: per-key reads on the Postgres primary, per-row
version-CAS writes with lockstep retry storms on hot group keys, inline
creation transactions during event processing, and ClickHouse produces
awaited inside flush.

- add updateGroupsBatch: one UNNEST statement per flush with server-side
  jsonb merge, so no version assertion and no conflict retries; gated by
  GROUP_BATCH_WRITING_USE_BATCH_UPDATES (default off)
- track properties_to_set deltas so batched writes only send changed keys
- skip the conflict retry when the refetched row already contains the
  update, and sync the cached version after successful writes
- create new groups with a single ON CONFLICT insert instead of a
  wrapping transaction
- return ClickHouse messages from flush as side effects instead of
  awaiting delivery inline; the flush step emits group-specific
  size warnings
- add prefetchGroups and prefetchGroupsStep: one batched read per chunk
  of $groupidentify events instead of a single-row fetch per group key;
  gated by GROUPS_PREFETCH_ENABLED (default off)
- jitter optimistic-retry intervals so conflicting pods don't re-collide
- extend fetchGroupsByKeys with created_at and version (Postgres and
  personhog) so prefetched entries are complete cache entries
Two pods flushing overlapping group sets in unsorted UNNEST order could
deadlock. Sorting by row identity makes concurrent statements lock
overlapping rows in the same order; the residual risk degrades to the
existing individual-write fallback.
@jose-sequeira jose-sequeira self-assigned this Jul 10, 2026
@trunk-io

trunk-io Bot commented Jul 10, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

@jose-sequeira
jose-sequeira marked this pull request as ready for review July 10, 2026 11:40
@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team July 10, 2026 11:40
@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "chore(ingestion): update fetchGroupsByKe..." | Re-trigger Greptile

Comment thread nodejs/src/ingestion/common/groups/batch-writing-group-store.ts
Comment thread nodejs/src/ingestion/pipelines/analytics/steps/prefetchGroupsStep.ts Outdated
@veria-ai

veria-ai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 2 · PR risk: 0/10

Comment thread nodejs/src/ingestion/common/groups/batch-writing-group-store.ts Outdated
Comment thread nodejs/src/ingestion/common/groups/batch-writing-group-store.ts Outdated
Comment thread nodejs/src/ingestion/common/groups/batch-writing-group-store.test.ts Outdated
- key batch flush result routing by full row identity (team, type
  index, key) instead of the cache's team:key, and verify multi-group
  routing in the test
- add GroupTypeManager.lookupGroupTypeIndex with own-property and type
  guards so attacker-supplied group types like __proto__ can't resolve
  to inherited values and poison SQL parameters; used by both the
  prefetch step and fetchGroupTypeIndex
- remove the unreachable version-mismatch branch from the create path
… effects

Group creation awaited the ClickHouse Kafka produce inline per event.
Delivery reports on the downstream producer take up to ~seconds under
backpressure (p90 765ms observed on the historical lane), so a
create-heavy workload on a single distinct_id serialized at ~200ms per
event, exceeded the consumer liveness window, and livelocked the
partition.

Creates and direct-upsert fallbacks now queue their ClickHouse message
in the store, and flush() drains them into its results, so they ride
the same side-effect production as flushed updates (still awaited
before offset commit; ReplacingMergeTree version ordering makes produce
order irrelevant). This drops the inline MessageSizeTooLarge handling —
oversized group messages surface through the flush step's
group-specific warning instead — and the store no longer needs the
ingestion outputs, so the constructor loses that parameter.
Comment thread nodejs/src/ingestion/common/groups/batch-writing-group-store.ts
Three parallel arrays representing (team_id, group_type_index, group_key)
tuples invited length mismatches at every call site. Take a single list of
key objects and unnest inside the repository instead.
The cache keyed entries by (team_id, group_key) only, so two group types
sharing a group key would read and overwrite each other's cached row,
merging properties across types and syncing the wrong row after a flush.
jose-sequeira and others added 2 commits July 13, 2026 17:15
…-batch-store-optimizations

# Conflicts:
#	nodejs/src/ingestion/pipelines/analytics/post-team-preprocessing-subpipeline.ts
@jose-sequeira
jose-sequeira merged commit c2139a8 into master Jul 14, 2026
176 checks passed
@jose-sequeira
jose-sequeira deleted the jose-sequeira/group-batch-store-optimizations branch July 14, 2026 07:38
@deployment-status-posthog

deployment-status-posthog Bot commented Jul 14, 2026

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-07-14 08:05 UTC Run
prod-us ✅ Deployed 2026-07-14 08:22 UTC Run
prod-eu ✅ Deployed 2026-07-14 08:25 UTC Run

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.

3 participants