Skip to content

perf: update each member once per data sink batch - #5125

Open
themarolt wants to merge 23 commits into
mainfrom
perf/issue-916-update-member-once-per-batch
Open

themarolt wants to merge 23 commits into
mainfrom
perf/issue-916-update-member-once-per-batch

Conversation

@themarolt

Copy link
Copy Markdown
Contributor

Summary

data-sink-worker updates the member of every activity one at a time, so in a 50-activity batch about 63% of memberService.update calls repeat work for a member already updated in the same batch. Member updates take about 40% of processActivities time on -normal, and the repeats add to row-lock contention on busy members and bots.

This PR updates each repeated member once per batch, with the combined data of all its activities.

Changes

  • New pre-pass updateRepeatedMembers, timed as activityService -> processActivities -> updateRepeatedMembers, runs before the per-activity loop for every member that appears more than once in the batch (as member or object member; an activity whose member and object member are the same person counts once):

    • sorts the member's activities by timestamp;
    • splits them into runs of consecutive activities that share a segment, integration and platform;
    • calls memberService.update once per run, in order, so the newest activity's data is written last.
  • combineMemberUpdates merges a run's member data:

    • attributes from the oldest to the newest activity;
    • identities from all activities (update normalizes and dedupes them, as before);
    • organizations from all activities;
    • the earliest valid joinedAt;
    • reach merged.

    The rule that ignores incoming attributes flagging a non-bot member as a GitHub bot is applied per activity, as mergeData does today.

  • The member row and identities are reloaded before a member is updated again, so every update starts from the stored state instead of the row loaded before the batch. That row used to keep only the last update's attributes and could move joinedAt later.

  • Merges: when an update merges the member away, its later runs update the surviving member (also reloaded first), and results of runs that ran earlier follow the redirect, through chains. memberMap is also seeded with these redirects, as a guard for payloads that resolve the same username to another member.

  • If a run's update fails, its activities are retried one by one in place. An activity that still fails is resolved there with handleMemberIdentityError, as the per-activity updates do: a merge redirect is followed, otherwise the error and its metadata go to the activity's result. The per-activity updates don't retry it, since they run after newer activities and would write its older data over theirs. Results are tracked per activity.

  • After the pre-pass, rows and identities of every updated member are reloaded for the per-activity loop.

  • joinedAt ignores dates within five days of the epoch (getEarliestValidDate uses the same tolerance for the stored date).

  • Email-domain stint dates stay per activity: update and assignOrganizationByEmailDomain take either one activity timestamp or the timestamps per email domain, and each domain's organization gets only the dates of the activities carrying an email on it. bufferMemberOrganizationActivityDates writes an organization's distinct dates in one Redis round trip and skips the write when there are none.

  • Members that appear once in the batch go through the per-activity path unchanged.

Notes for reviewers:

  • A member active in two segments or integrations in the same batch still gets one update per run, as update is per segment and integration.
  • Older activities in a batch now read isTeamMember/isBot defaults from the member as updated by the batch, as the next batch would.
  • Part of the member-update time moves from Promise.all (memberService.update) to the new updateRepeatedMembers timer. Compare the sum of both against the old metric, or memberService -> update.
  • Repeated members are updated one after another, as the per-activity loop did.
  • An activity whose update keeps failing (for example an identity conflict blocked by a no-merge pair, linuxfoundation/lfx-cdp-insights-ops#921) costs two update attempts instead of one: its run's and its own.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Performance improvement
  • Chore / dependency update
  • Documentation

Related issue

Closes linuxfoundation/lfx-cdp-insights-ops#916

A batch of 50 activities has only about 18 distinct members, but every
activity updated its member again. Members with several activities in a
batch are now updated once per segment with their combined data:
attributes from oldest to newest, identities and organizations
together, the earliest joinedAt, and every activity date for the
email-domain organization dates. A combined update that fails falls back
to the per-activity updates, so error handling is unchanged.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>
Apply the bot-attribute check per activity so one flagged activity
doesn't drop the others' attributes, ignore empty, invalid and pre-1970
joinedAt values, and refresh a member's identities after a combined
update so later updates of the same member don't hit stale conflicts.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>
…-member-once-per-batch

Signed-off-by: Uroš Marolt <uros@marolt.me>
A combined member update buffered every activity date of the group for
every email domain organization, so a member with an old and a new work
email got both stints spanning both dates. Each domain now gets only the
dates of the activities that carry it, written in one Redis round trip.

A group for a member that an earlier group merged away now reuses the
redirect instead of updating the absorbed member again. Identity dedup is
left to update(), which already normalizes and dedupes them.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>
A combined member update recorded before a later update merged the same
member away kept the absorbed member's id, and the per-activity loop
used it without checking memberMap. Group results now follow redirects
found later in the pre-pass, and a known redirect in memberMap wins over
a group's own member id in the loop.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>
Every update of a member in a batch started from the member row loaded
before the batch, and updateMember writes the whole attributes object, so
a member updated for several segments kept only the last update's
attributes and could get a later joinedAt. The pre-pass now reloads the
member row along with its identities before updating the same member
again, and hands the reloaded rows to the per-activity updates.

Dates within five days of the epoch no longer count towards joinedAt,
matching getEarliestValidDate.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>
When a combined update merged a member into another, the target member's
own update later in the batch still started from its row and identities
from before the merge, and could overwrite what the merge stored.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>
…-member-once-per-batch

Signed-off-by: Uroš Marolt <uros@marolt.me>
Single-activity updates of a member ran after its combined updates, so an
older activity could overwrite a newer one's attributes. Every member that
appears more than once in a batch now goes through the pre-pass, its
groups ordered by their newest activity, and redirect chains are followed
to the end.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>
…-member-once-per-batch

Signed-off-by: Uroš Marolt <uros@marolt.me>
Combined groups of one member could interleave in time, so a group with
an older activity could still be written after a newer one, a group of
a member merged away earlier was skipped, and a failed group's
per-activity retry ran after newer updates.

The pre-pass now sorts each repeated member's activities by timestamp and
updates runs of consecutive activities that share a segment, integration
and platform, in order, refreshing the member between them. Runs of a
merged member update the surviving member, and a failed run is retried
per activity in place. Results are tracked per activity, so only
activities that fail on their own fall back to the per-activity loop.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>
An activity whose member update kept failing was tried again by the
per-activity updates after newer activities of the same member, and each
attempt wrote its older attributes over theirs. An activity left out of
the pre-pass could also skip its update through a merge redirect and be
reported as stored.

A failed run is now retried per activity in place, and an activity that
still fails is resolved there with handleMemberIdentityError: a redirect
is followed, otherwise the error and its metadata go to the result, and
the per-activity updates no longer touch it.

Also groups the pure helpers at module level and names the run ordering.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>
…-member-once-per-batch

Signed-off-by: Uroš Marolt <uros@marolt.me>
Copilot AI balanced review requested due to automatic review settings October 26, 2026 19:38
@cursor

cursor Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

PR Summary

High Risk
Changes core activity ingestion member-update ordering, merging, and redirect handling; incorrect batch combining could corrupt member attributes, joinedAt, or stint dates despite extensive edge-case logic.

Overview
Replaces per-activity memberService.update calls with a batch updateMembers pass that groups work by member, merges multiple activities via combineMemberUpdates (attributes, identities, orgs, earliest valid joinedAt, reach), and issues one update per timestamp-ordered run (split by segment/integration/platform). Merge redirects are tracked and member rows are refreshed before subsequent updates; combined runs fall back to per-activity updates on failure.

Also rejects activities with invalid timestamps during member prep, fixes memberAttValue when incoming attributes are plain values vs per-platform objects, and passes per-email-domain activity timestamps into org stint buffering (ActivityTimestampsByEmailDomain, multi-date Redis sAdd). Identity redirect sync always attaches survivingId on post-merge constraint errors.

Reviewed by Cursor Bugbot for commit 1978741. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI 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.

🟡 Changes recommended

Unresolved merge recovery, cross-member update ordering, and attribute-handling issues can fail valid activities or produce incorrect member and activity data.

4 open issues
What changed in this PR

This PR reduces repeated member updates in the data-sink worker by combining activity data into chronological runs, aiming to reduce database work and row-lock contention.

Changes:

  • Adds a repeated-member pre-pass with combined updates, merge redirects, refreshes, and per-activity retries.
  • Preserves email-domain activity timestamps for organization stint tracking.
  • Batches distinct organization activity dates into Redis writes.
File Description
services/​apps/​data_sink_worker/​src/​service/​member.service.ts Accepts per-domain timestamps and batches organization date buffering.
services/​apps/​data_sink_worker/​src/​service/​member.data.ts Defines the email-domain timestamp map type.
services/​apps/​data_sink_worker/​src/​service/​activity.service.ts Groups repeated-member updates and handles retries, redirects, and refreshed member state.

Review effort: Balanced

Comment thread services/apps/data_sink_worker/src/service/activity.service.ts Outdated
Comment thread services/apps/data_sink_worker/src/service/activity.service.ts Outdated
Comment thread services/apps/data_sink_worker/src/service/activity.service.ts Outdated
Comment thread services/apps/data_sink_worker/src/service/activity.service.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f6bdd10. Configure here.

Comment thread services/apps/data_sink_worker/src/service/activity.service.ts Outdated
Members that merge into each other mid-batch could have an older run of
the survivor written after the newer merged data, so all repeated members'
runs now go in one timestamp order. A combined update that merged the
member and then failed records the survivor from the error before retrying
per activity. Redirected activities keep the survivor's row for the bot and
team-member flags, and a plain isBot value on GitHub counts as a bot flag,
as it does once validateAttributes normalizes it.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>
Copilot AI balanced review requested due to automatic review settings October 26, 2026 19:50
…-member-once-per-batch

Signed-off-by: Uroš Marolt <uros@marolt.me>
A repeated member could merge into a member that appears once in the
batch, and that member's own update, still run per activity after the
pre-pass, then wrote its older attributes and reach over the newer merged
data. Every existing member's update now goes through the pre-pass, in one
timestamp order across the batch. Members that appear once get a single
attempt, as before; the loop only applies the results, so its
per-activity updates and username redirect map are gone.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>

Copilot AI 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.

🔵 Needs a closer look

Merge ordering can overwrite newer member data, and primitive activity flags can lose their explicit values.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Primitive activity flags are ignored during attribute refresh

services/​apps/​data_sink_worker/​src/​service/​activity.service.ts:228

Combining attributes into a new object leaves primitive flags in the original activity payloads unnormalized, but memberAttValue only reads platform-keyed values. With repeated updates carrying isTeamMember: true then isTeamMember: false, the older activity's explicit true is ignored and it receives the refreshed member's false default. Preserve per-activity attribute normalization before reading flags, or make memberAttValue handle validated primitive values.

4 resolved since last review

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 26, 2026 19:55
The per-activity update used to normalize the activity's own attributes in
place, turning a plain isTeamMember or isBot value into { [platform]: value }
before memberAttValue read it. The combined update validates a copy, so
memberAttValue now reads a plain value directly.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>
@themarolt

Copy link
Copy Markdown
Contributor Author

Re: Copilot review · findings outside review threads

  • Primitive activity flags are ignored during attribute refresh · services/apps/data_sink_worker/src/service/activity.service.ts:228
    Fixed in f7506c8: memberAttValue now reads a plain isTeamMember/isBot value on the activity as that value, the way validateAttributes stores it ({ [platform]: value }). The per-activity update used to normalize the activity's own attributes in place; the combined update validates a copy, so the activity kept the plain value.

Copilot AI 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.

🟡 Changes recommended

Overlapping runs can overwrite newer data, location aggregation can discard valid values, and singleton updates lose existing concurrency.

3 open issues

Review effort: Balanced

Comment thread services/apps/data_sink_worker/src/service/activity.service.ts
Comment thread services/apps/data_sink_worker/src/service/activity.service.ts Outdated
Comment thread services/apps/data_sink_worker/src/service/activity.service.ts
Copilot AI balanced review requested due to automatic review settings October 26, 2026 20:01
update() removes URL values from location after the combined attributes
are merged, so a valid location from an older activity was lost when a
newer activity sent a URL for the same platform. Each activity's location
is now filtered before the merge, as its own update would have done.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>

Copilot AI 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.

🔵 Needs a closer look

Invalid email identities can contribute organization activity dates despite being rejected during member updates, corrupting inferred stint history.

1 open issue
1 previously missed issue

In code that hasn't changed since last review

Medium severity Exclude invalid emails from organization timestamp tracking

services/​apps/​data_sink_worker/​src/​service/​activity.service.ts:240

The timestamp map includes invalid email identities that MemberService.update later drops. A run with verified @acme.com at t1 and user@acme.com at t2 therefore buffers both dates for acme.com, incorrectly extending its inferred organization stint. Apply normalizeMemberIdentities with dropInvalidEmails: true before collecting timestamps so only accepted emails contribute dates.

2 resolved since last review

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 26, 2026 20:07
update() drops invalid emails before assigning organizations by email
domain, but the per-domain timestamps still counted them, so an invalid
verified email could add its activity's date to a domain that a valid
email in the same run also carries.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>
@themarolt

Copy link
Copy Markdown
Contributor Author

Re: Copilot review · findings outside review threads

  • Exclude invalid emails from organization timestamp tracking · services/apps/data_sink_worker/src/service/activity.service.ts:240
    Fixed in 9924553: emailDomainTimestamps now skips emails that fail isValidEmail after normalizeMemberIdentityValue, the same check normalizeMemberIdentities(…, { dropInvalidEmails: true }) applies in update(), so only emails update() keeps add dates to a domain.

Copilot AI 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.

🔵 Needs a closer look

The interaction between combined updates, merge redirects, and retries needs human validation of ordering and data preservation.

1 open issue

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 26, 2026 20:14
An unparseable activity timestamp used to fail only that activity when
its per-activity member update threw on the joinedAt derived from it. The
combined update skips invalid dates, so the error surfaced later in
prepareForUpsert and failed the whole batch. Such activities now get an
UnrepeatableError before any member work, and a redirect recorded after
an earlier one follows the chain to the current survivor.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>

Copilot AI 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.

🔵 Needs a closer look

Batch-wide update ordering, merge redirects, and fallback retries change core ingestion behavior and warrant final human validation.

1 open issue

Review effort: Balanced

…tivity

syncIdentitiesAfterRedirect only attached survivingId to an insert
conflict after a second redirect. After the first one the member that
started the update is already absorbed, so a run that merged its member
away and then hit another conflict retried every activity on the
absorbed member and failed them all. The conflict now always carries the
survivor.

Runs are now ordered by their newest activity instead of their oldest,
so when members merge mid-batch the merged member's newest data, which
the merge carries into the survivor, is not followed by an older run of
the survivor.

Refs linuxfoundation/lfx-cdp-insights-ops#916

Signed-off-by: Uroš Marolt <uros@marolt.me>
Copilot AI balanced review requested due to automatic review settings October 27, 2026 05:08

Copilot AI 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.

🟡 Changes recommended

Merge-related paths can target an absorbed member or overwrite newer reach values with older data.

1 open issue
1 previously missed issue

In code that hasn't changed since last review

Medium severity Report the actual surviving ID in merge-limit errors

services/​apps/​data_sink_worker/​src/​service/​activity.service.ts:1981

The merge-limit error reports an absorbed member as the survivor because syncIdentitiesAfterRedirect checks the limit after a successful merge but before advancing survivingId. Recording that ID here sends retries and later runs to the wrong row, where another merge can fail. Change the merge-limit error in member.service.ts:319–324 to report survivingId: nextRedirectId, so this redirect targets the actual survivor.

1 resolved since last review

Review effort: Balanced

Comment on lines +2018 to +2022
.sort(
(a, b) =>
timestampMs(a.run[a.run.length - 1].timestamp) -
timestampMs(b.run[b.run.length - 1].timestamp),
)
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.

2 participants