fix: tombston in inferMemberOrganizationStintChanges (CM-1367) - #4502
Conversation
PR SummaryMedium Risk Overview The Operational hardening on the same job: FK violation (23503) handling with Redis-backed retries (purge poisoned queue entries after three failures), batched affiliation-policy fetches for inserts, and cleanup of FK-retry keys on successful processing and purge. Reviewed by Cursor Bugbot for commit 4b2128f. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Pull request overview
Prevents manually deleted email-domain affiliations from being recreated during stint inference.
Changes:
- Propagates
deletedBythrough organization types and DAL queries. - Skips all inferred changes for human-tombstoned organizations.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
services/libs/types/src/organizations.ts |
Adds deletedBy to the affiliation type. |
services/libs/data-access-layer/src/members/organizations.ts |
Selects the tombstone actor field. |
services/libs/common_services/src/services/member-organization.ts |
Suppresses inference for tombstoned organizations. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
6ca8f4b to
daa5124
Compare
598c4b7 to
ee67b60
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
❌ 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 ee67b60. Configure here.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Suppressed comments (1)
services/apps/cron_service/src/jobs/inferMemberOrganizationStintChanges.job.ts:164
- These lines only restate the function's behavior. Repository guidance disallows comments that merely describe what code does (
CLAUDE.md:72-84); the function name already conveys the reconciliation. Remove the comment.
// Merged orgs are rewritten to their primary id instead of dropped; only genuinely
// deleted orgs are dropped, since those can never resolve to a valid target.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Suppressed comments (3)
services/apps/cron_service/src/jobs/inferMemberOrganizationStintChanges.job.ts:204
- A missing ID is not necessarily deleted: the organization merge workflow hard-deletes the secondary before it marks the merge action
MERGED(services/apps/entity_merging_worker/src/workflows/all.ts:116-119). If this cron runs in that window,findMergedPrimaryIdsreturns nothing, this line classifies the ID as deleted, and the queued date is permanently acknowledged. Detect an in-progress merge and leave those values queued for retry instead of dropping them.
const deletedOrgIds = new Set(missingOrgIds.filter((id) => !mergedPrimaryIds.has(id)))
services/apps/cron_service/src/jobs/inferMemberOrganizationStintChanges.job.ts:142
- Purging the whole member key after the third FK failure loses unrelated and concurrently added dates. The normal success path deliberately uses
ackSetMembersso values added aftersMemberssurvive, butpurgeMemberdeletes the entire set; one poisoned organization can therefore discard valid activity for every organization on this member. Isolate/ack only the failing snapshot or offending organization and preserve newly queued values.
await purgeMember(redis, memberId)
services/apps/cron_service/src/jobs/inferMemberOrganizationStintChanges.job.ts:179
- These lines only narrate the function's behavior and violate the repository rule that code should be self-explanatory and comments must be reserved for non-obvious invariants or external quirks (
CLAUDE.md:72-84). The function name already communicates the reconciliation intent, so remove the comment.
// Merged orgs are rewritten to their primary id instead of dropped; only genuinely
// deleted orgs are dropped, since those can never resolve to a valid target.
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
Signed-off-by: Umberto Sgueglia <usgueglia@contractor.linuxfoundation.org>
6892f17 to
4b2128f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
services/apps/cron_service/src/jobs/inferMemberOrganizationStintChanges.job.ts:196
findOrgsByIdstreats soft-deleted organizations as existing because its query has nodeletedAtpredicate (services/libs/data-access-layer/src/organizations/base.ts:119-132). A stale activity for such an organization therefore survives reconciliation and can create or extend an email-domain affiliation attached to a deleted profile. Query active organizations here and route soft-deleted IDs through the deleted/merged handling.
const existingOrgIds = new Set((await findOrgsByIds(qx, orgIdsToVerify)).map((o) => o.id))
services/apps/cron_service/src/jobs/inferMemberOrganizationStintChanges.job.ts:218
- Only the first merge hop is resolved. For a valid chain such as A→B followed later by B→C, this maps queued dates for A to the now-deleted B; the insert then fails its FK and the new retry path eventually purges the dates. Resolve the chain to the current surviving primary before returning the mapping.
.map((d) =>
mergedPrimaryIds.has(d.organizationId)
? { ...d, organizationId: mergedPrimaryIds.get(d.organizationId) }
: d,
services/apps/cron_service/src/jobs/inferMemberOrganizationStintChanges.job.ts:179
- This comment only restates the function's implementation. Project guidance in
CLAUDE.md:72-84explicitly prohibits comments that explain what code does; remove it and rely on the descriptive function and variable names.
// Merged orgs are rewritten to their primary id instead of dropped; only genuinely
// deleted orgs are dropped, since those can never resolve to a valid target.
services/apps/cron_service/src/jobs/inferMemberOrganizationStintChanges.job.ts:135
- The PR description lists only tombstone propagation, but this adds a separate failure policy that suppresses every FK violation and permanently discards the member's queued dates after three attempts. That is a significant behavioral/data-loss change and should be split into its own PR or explicitly documented with its intended constraints and validation.
if ((err as { code?: string })?.code === '23503') {
const constraint = (err as { constraint?: string })?.constraint
const retryKey = fkViolationRetryKey(memberId)
const retries = await redis.incr(retryKey)
| AND ma.state = $(state) | ||
| AND ma."secondaryId" = ANY($(secondaryIds)::uuid[]) |

Summary
Fixes DE-1016/DE-1021: a human-deleted
email-domainsourced org affiliation can reappear on a member profile once new matching activity comes in, because theinfer-member-organization-stint-changescron job only checkeddeletedAtand neverdeletedBy. PR #4476 (CM-1367) already fixed this for provider/enrichment-sourced affiliations, but the email-domain stint-inference path is a separate codepath that PR didn't touch. This PR closes that gap by making a human tombstone (deletedByset) permanently block recreation of that org affiliation, regardless of whether the new activity date falls inside the deleted row's date range.Changes
deletedBy?: stringtoIMemberOrganization(services/libs/types/src/organizations.ts) so the field can flow through to the stint-inference logic."deletedBy"to the SELECT column list infetchMemberOrganizationsBySource(services/libs/data-access-layer/src/members/organizations.ts) — the cron job already fetches deleted rows viawithDeleted: true, it just wasn't reading who deleted them.inferMemberOrganizationStintChanges(services/libs/common_services/src/services/member-organization.ts) now computes atombstonedOrgIdsset from rows where bothdeletedAtanddeletedByare set, and skips any stint insert/extend for thoseorganizationIds outright. This is intentionally stronger than the existingdeletedRowssuppression, which only blocks recreation for activity dates that intersect the deleted row's own date range — a human delete should hold regardless of date, while a system/provider delete (nodeletedBy) keeps the narrower, date-scoped suppression.Type of change
JIRA ticket
CM-13267