Skip to content

Commit da4ab4b

Browse files
authored
Merge pull request #131 from linuxfoundation/jme/LFXV2-2673
fix(committee-members): sync contact SFID (MemberID) to v1 on delete
2 parents fa47d38 + 099e6b6 commit da4ab4b

7 files changed

Lines changed: 909 additions & 15 deletions

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,24 @@ lfx-v1-sync-helper --backfill-acs-project [--dry-run]
176176
lfx-v1-sync-helper --backfill-acs-org [--dry-run]
177177
```
178178

179+
**Committee-member reverse-mapping repair (`--backfill-committee-member-mappings`):**
180+
181+
Repairs committee-member reverse mappings (`committee_member.uid.*` in `v1-mappings`) that store the `platform-community__c` record sfid instead of the contact SFID (v1 API "MemberID") — the root cause of v1 committee-member deletes 404ing and leaving members on the committee / meeting invites (LFXV2-2673). Resolves the contact SFID from the `v1-objects` KV bucket; makes no calls to any other service. Writes are optimistic-concurrency guarded against the live sync-helper, idempotent, and safe to re-run.
182+
183+
Run once after deploying the fix, to repair already-affected mappings. A dry-run pass is recommended first:
184+
185+
```sh
186+
kubectl --context lfx-v2-prod -n v1-sync-helper apply -f manifests/backfill-committee-member-mappings-job.yaml
187+
```
188+
189+
Add `--dry-run` to the manifest args, apply, inspect logs (`inspected`/`poisoned`/`fixed`/`already_ok`/`unresolved`/`malformed`/`tombstoned`/`conflicted` counts), then re-apply without it for the live run.
190+
191+
Locally (with NATS port-forwarded):
192+
193+
```sh
194+
lfx-v1-sync-helper --backfill-committee-member-mappings [--dry-run]
195+
```
196+
179197
## Architecture Diagrams
180198

181199
Regarding the following sequence diagrams:

cmd/lfx-v1-sync-helper/backfill_committee_member_mappings.go

Lines changed: 396 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
// The lfx-v1-sync-helper service.
5+
package main
6+
7+
import "testing"
8+
9+
// Sample SFIDs/UUIDs used across the classification and parsing tests below.
10+
const (
11+
testProjectSFID = "a0941000002wBz9AAE"
12+
testCommitteeSFID = "a0941000002wCz9AAE"
13+
testRecordSFID = "51fde723-67df-4e0e-91c6-936d01d59559" // UUID: platform-community__c record sfid
14+
testContactSFID = "0031000001AbCdeAAB" // Salesforce ID: contact_name__c
15+
)
16+
17+
func TestClassifyReverseMapping(t *testing.T) {
18+
tests := []struct {
19+
name string
20+
data []byte
21+
want reverseMappingOutcome
22+
wantRec string // expected recordSFID, when applicable
23+
}{
24+
{
25+
name: "empty value is malformed, not tombstoned",
26+
data: []byte(""),
27+
want: reverseMappingMalformed,
28+
},
29+
{
30+
name: "explicit tombstone marker",
31+
data: []byte(tombstoneMarker),
32+
want: reverseMappingTombstoned,
33+
},
34+
{
35+
name: "legacy 3-field mapping with UUID third field needs a fix",
36+
data: []byte(testProjectSFID + ":" + testCommitteeSFID + ":" + testRecordSFID),
37+
want: reverseMappingNeedsFix,
38+
},
39+
{
40+
name: "legacy 3-field mapping with contact SFID third field is already OK",
41+
data: []byte(testProjectSFID + ":" + testCommitteeSFID + ":" + testContactSFID),
42+
want: reverseMappingAlreadyOK,
43+
},
44+
{
45+
name: "3-field mapping with empty third field is malformed",
46+
data: []byte(testProjectSFID + ":" + testCommitteeSFID + ":"),
47+
want: reverseMappingMalformed,
48+
},
49+
{
50+
name: "value with no colons is malformed",
51+
data: []byte("not-a-mapping"),
52+
want: reverseMappingMalformed,
53+
},
54+
{
55+
name: "value with one colon is malformed",
56+
data: []byte(testProjectSFID + ":" + testCommitteeSFID),
57+
want: reverseMappingMalformed,
58+
},
59+
}
60+
61+
for _, tt := range tests {
62+
t.Run(tt.name, func(t *testing.T) {
63+
got := classifyReverseMapping(tt.data)
64+
if got.outcome != tt.want {
65+
t.Fatalf("classifyReverseMapping(%q).outcome = %v, want %v", tt.data, got.outcome, tt.want)
66+
}
67+
})
68+
}
69+
}
70+
71+
func TestClassifyReverseMapping_NeedsFixCarriesFields(t *testing.T) {
72+
data := []byte(testProjectSFID + ":" + testCommitteeSFID + ":" + testRecordSFID)
73+
got := classifyReverseMapping(data)
74+
75+
if got.outcome != reverseMappingNeedsFix {
76+
t.Fatalf("outcome = %v, want %v", got.outcome, reverseMappingNeedsFix)
77+
}
78+
if got.projectSFID != testProjectSFID {
79+
t.Errorf("projectSFID = %q, want %q", got.projectSFID, testProjectSFID)
80+
}
81+
if got.committeeSFID != testCommitteeSFID {
82+
t.Errorf("committeeSFID = %q, want %q", got.committeeSFID, testCommitteeSFID)
83+
}
84+
if got.recordSFID != testRecordSFID {
85+
t.Errorf("recordSFID = %q, want %q", got.recordSFID, testRecordSFID)
86+
}
87+
}
88+
89+
func TestBuildRepairedReverseMappingValue(t *testing.T) {
90+
got := buildRepairedReverseMappingValue(testProjectSFID, testCommitteeSFID, testContactSFID)
91+
want := testProjectSFID + ":" + testCommitteeSFID + ":" + testContactSFID
92+
if got != want {
93+
t.Fatalf("buildRepairedReverseMappingValue() = %q, want %q", got, want)
94+
}
95+
96+
// The repaired value must itself classify as already OK and re-parse to the same
97+
// contact SFID, so a repaired mapping is stable under a second backfill pass.
98+
class := classifyReverseMapping([]byte(got))
99+
if class.outcome != reverseMappingAlreadyOK {
100+
t.Fatalf("classifyReverseMapping(repaired value).outcome = %v, want %v", class.outcome, reverseMappingAlreadyOK)
101+
}
102+
103+
gotProject, gotCommittee, gotRecord, gotContact, ok := parseCommitteeMemberReverseMapping(got)
104+
if !ok {
105+
t.Fatalf("parseCommitteeMemberReverseMapping(%q) failed to parse", got)
106+
}
107+
if gotProject != testProjectSFID || gotCommittee != testCommitteeSFID || gotRecord != "" || gotContact != testContactSFID {
108+
t.Fatalf("parseCommitteeMemberReverseMapping(%q) = (%q, %q, %q, %q), want (%q, %q, %q, %q)",
109+
got, gotProject, gotCommittee, gotRecord, gotContact,
110+
testProjectSFID, testCommitteeSFID, "", testContactSFID)
111+
}
112+
}
113+
114+
func TestIsUUID(t *testing.T) {
115+
tests := []struct {
116+
name string
117+
s string
118+
want bool
119+
}{
120+
{"canonical lowercase UUID", "51fde723-67df-4e0e-91c6-936d01d59559", true},
121+
{"canonical uppercase UUID", "51FDE723-67DF-4E0E-91C6-936D01D59559", true},
122+
{"salesforce 18-char id", testContactSFID, false},
123+
{"salesforce 15-char id", "a094100000TFAKE", false},
124+
{"empty string", "", false},
125+
{"wrong length", "51fde723-67df-4e0e-91c6-936d01d5955", false},
126+
{"missing hyphens", "51fde72367df4e0e91c6936d01d59559", false},
127+
{"hyphens in wrong place", "51fde7236-7df-4e0e-91c6-936d01d59559", false},
128+
{"non-hex character", "51fde723-67df-4e0e-91c6-936d01d5955g", false},
129+
}
130+
131+
for _, tt := range tests {
132+
t.Run(tt.name, func(t *testing.T) {
133+
if got := isUUID(tt.s); got != tt.want {
134+
t.Errorf("isUUID(%q) = %v, want %v", tt.s, got, tt.want)
135+
}
136+
})
137+
}
138+
}
139+
140+
func TestParseCommitteeMemberReverseMapping(t *testing.T) {
141+
tests := []struct {
142+
name string
143+
s string
144+
wantOK bool
145+
wantProject string
146+
wantCommittee string
147+
wantRecord string
148+
wantContact string
149+
}{
150+
{
151+
name: "legacy 3-field mapping with record sfid",
152+
s: testProjectSFID + ":" + testCommitteeSFID + ":" + testRecordSFID,
153+
wantOK: true,
154+
wantProject: testProjectSFID,
155+
wantCommittee: testCommitteeSFID,
156+
wantRecord: testRecordSFID,
157+
wantContact: "",
158+
},
159+
{
160+
name: "legacy 3-field mapping with contact sfid",
161+
s: testProjectSFID + ":" + testCommitteeSFID + ":" + testContactSFID,
162+
wantOK: true,
163+
wantProject: testProjectSFID,
164+
wantCommittee: testCommitteeSFID,
165+
wantRecord: "",
166+
wantContact: testContactSFID,
167+
},
168+
{
169+
name: "too few fields",
170+
s: testProjectSFID + ":" + testCommitteeSFID,
171+
wantOK: false,
172+
},
173+
{
174+
// splitThreeParts only splits on the first two colons, so everything after
175+
// the second colon (including further colons) folds into the third field;
176+
// the result is neither a UUID nor a valid SFID, so it must be rejected as
177+
// malformed rather than misclassified as a usable contact SFID.
178+
name: "extra colons fold into an invalid third field",
179+
s: testProjectSFID + ":" + testCommitteeSFID + ":" + testContactSFID + ":extra",
180+
wantOK: false,
181+
},
182+
{
183+
name: "third field is neither a UUID nor a valid SFID",
184+
s: testProjectSFID + ":" + testCommitteeSFID + ":not-a-sfid",
185+
wantOK: false,
186+
},
187+
{
188+
name: "no colons",
189+
s: "not-a-mapping",
190+
wantOK: false,
191+
},
192+
}
193+
194+
for _, tt := range tests {
195+
t.Run(tt.name, func(t *testing.T) {
196+
gotProject, gotCommittee, gotRecord, gotContact, gotOK := parseCommitteeMemberReverseMapping(tt.s)
197+
if gotOK != tt.wantOK {
198+
t.Fatalf("parseCommitteeMemberReverseMapping(%q) ok = %v, want %v", tt.s, gotOK, tt.wantOK)
199+
}
200+
if !tt.wantOK {
201+
return
202+
}
203+
if gotProject != tt.wantProject || gotCommittee != tt.wantCommittee || gotRecord != tt.wantRecord || gotContact != tt.wantContact {
204+
t.Fatalf("parseCommitteeMemberReverseMapping(%q) = (%q, %q, %q, %q), want (%q, %q, %q, %q)",
205+
tt.s, gotProject, gotCommittee, gotRecord, gotContact,
206+
tt.wantProject, tt.wantCommittee, tt.wantRecord, tt.wantContact)
207+
}
208+
})
209+
}
210+
}

cmd/lfx-v1-sync-helper/handlers_committees.go

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"fmt"
1010
"strings"
1111

12+
sfidvalidator "github.com/linuxfoundation/lfx-v1-sync-helper/internal/sfid"
1213
committeeservice "github.com/linuxfoundation/lfx-v2-committee-service/gen/committee_service"
1314
"github.com/nats-io/nats.go/jetstream"
1415
)
@@ -291,6 +292,14 @@ func handleCommitteeMemberDelete(ctx context.Context, key string, sfid string, v
291292
logger.With(errKey, err, "committee_uid", committeeUID, "member_uid", memberUID).WarnContext(ctx, "failed to tombstone committee member UID mapping")
292293
}
293294

295+
// Tombstone the record-sfid companion too, or it is left as a permanent live key:
296+
// the v2-side delete indexer event that would otherwise clean it up either finds
297+
// the reverse mapping already tombstoned above, or its own v1 delete call 404s
298+
// since this v1-originated deletion already removed the member.
299+
if err := tombstoneMapping(ctx, committeeMemberRecordSFIDKey(memberUID)); err != nil {
300+
logger.With(errKey, err, "committee_uid", committeeUID, "member_uid", memberUID).WarnContext(ctx, "failed to tombstone committee member record sfid mapping")
301+
}
302+
294303
logger.With("committee_uid", committeeUID, "member_uid", memberUID, "sfid", sfid, "key", key).InfoContext(ctx, "successfully deleted committee member")
295304
return false
296305
}
@@ -520,6 +529,26 @@ func handleCommitteeMemberUpdate(ctx context.Context, key string, v1Data map[str
520529
return
521530
}
522531

532+
// Extract the contact SFID (contact_name__c). This is the identifier the v1
533+
// project-service DELETE/PATCH .../committees/{c}/members/{MemberID} endpoints
534+
// match on (SQL: WHERE contact_name__c = :memberID) — it is the v1 API's
535+
// "MemberID", NOT the platform-community__c record sfid (the v1 API's "ID").
536+
// Storing the record sfid in the reverse mapping causes v1 member deletes to
537+
// 404 and leaves members on the committee / meeting invites (LFXV2-2673).
538+
contactSFID, _ := v1Data["contact_name__c"].(string)
539+
contactSFID = strings.TrimSpace(contactSFID)
540+
if contactSFID == "" {
541+
logger.With("sfid", sfid).WarnContext(ctx, "missing contact_name__c on committee member; not storing reverse mapping, v1 delete/update sync will not resolve the member")
542+
} else if !sfidvalidator.IsValid(contactSFID) {
543+
// parseCommitteeMemberReverseMapping rejects a non-UUID, non-SFID third field
544+
// as malformed, so a mapping written with an invalid contactSFID here would
545+
// immediately be treated as unusable by every reader. Drop it instead of
546+
// publishing a value the readers will reject anyway.
547+
logger.With("sfid", sfid, "contact_sfid", contactSFID).
548+
WarnContext(ctx, "invalid contact_name__c on committee member; not storing reverse mapping, v1 delete/update sync will not resolve the member")
549+
contactSFID = ""
550+
}
551+
523552
// Check for blank email and skip with warning.
524553
email := ""
525554
if contactEmail, ok := v1Data["contactemail__c"].(string); ok && contactEmail != "" {
@@ -633,10 +662,38 @@ func handleCommitteeMemberUpdate(ctx context.Context, key string, v1Data map[str
633662
projectSFID = projSFID
634663
}
635664

636-
reverseMappingKey := fmt.Sprintf("committee_member.uid.%s", memberUID)
637-
reverseMappingValue := fmt.Sprintf("%s:%s:%s", projectSFID, collaborationNameV1, sfid)
638-
if _, err := mappingsKV.Put(ctx, reverseMappingKey, []byte(reverseMappingValue)); err != nil {
639-
logger.With(errKey, err, "committee_uid", committeeUID, "member_uid", memberUID).WarnContext(ctx, "failed to store committee member reverse mapping")
665+
// The third field must be the contact SFID (v1 API "MemberID"), which the v1
666+
// delete/update endpoints match on — not the record sfid (LFXV2-2673). Without a
667+
// contact SFID the mapping cannot drive a v1 delete/update at all, and would
668+
// otherwise overwrite any existing usable mapping, so skip the write entirely.
669+
//
670+
// The reverse mapping is kept to exactly three fields (same field count as
671+
// before this fix) so a rolling deploy never has an old pod (parsing this same
672+
// key with the previous release's splitThreeParts-based reader) misparse a
673+
// value written by a new pod. The record sfid needed to tombstone the forward
674+
// mapping "committee_member.sfid.<sfid>" on delete is instead stored in the
675+
// separate committeeMemberRecordSFIDKey — see parseCommitteeMemberReverseMapping
676+
// in ingest_indexer.go.
677+
if contactSFID == "" {
678+
logger.With("sfid", sfid, "member_uid", memberUID).
679+
WarnContext(ctx, "not storing committee member reverse mapping: missing contact_name__c")
680+
} else {
681+
// Write the record-sfid companion before the reverse mapping itself: once
682+
// the reverse mapping is published with a contact SFID, the backfill
683+
// classifies it as already OK and never revisits it, so a companion that
684+
// failed to write here would never get repaired. If this write fails,
685+
// skip the reverse mapping write too so it isn't left inconsistent —
686+
// the next update event for this v1 record will retry both.
687+
if _, err := mappingsKV.Put(ctx, committeeMemberRecordSFIDKey(memberUID), []byte(sfid)); err != nil {
688+
logger.With(errKey, err, "committee_uid", committeeUID, "member_uid", memberUID).
689+
WarnContext(ctx, "failed to store committee member record sfid mapping, skipping reverse mapping write to avoid publishing it without a companion")
690+
} else {
691+
reverseMappingKey := fmt.Sprintf("committee_member.uid.%s", memberUID)
692+
reverseMappingValue := fmt.Sprintf("%s:%s:%s", projectSFID, collaborationNameV1, contactSFID)
693+
if _, err := mappingsKV.Put(ctx, reverseMappingKey, []byte(reverseMappingValue)); err != nil {
694+
logger.With(errKey, err, "committee_uid", committeeUID, "member_uid", memberUID).WarnContext(ctx, "failed to store committee member reverse mapping")
695+
}
696+
}
640697
}
641698
}
642699

0 commit comments

Comments
 (0)