Skip to content

Commit e5f41c3

Browse files
committed
refactor(users): replace primary-email cache with username KV index (LFXV2-2645)
The committee service now indexes members by username, so the user-deleted event no longer needs to carry an email address. Remove the v1-user.primary-email.* cache written by handleAlternateEmailUpdate, the getCachedPrimaryEmailForUser fallback wrapper, and the email lookup from handleMergedUserDelete. publishUserDeletedEvent now publishes only {username}; the committee service uses its new username secondary index to find affected members directly. Generated with [Claude Code](https://claude.ai/claude-code) Signed-off-by: Andres Tobon <andrest2455@gmail.com>
1 parent 5998bd8 commit e5f41c3

2 files changed

Lines changed: 14 additions & 82 deletions

File tree

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

Lines changed: 8 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,9 @@ import (
2020

2121
const (
2222
// KV key prefixes for secondary indexes written to v1-mappings.
23-
kvKeyUsernamePrefix = "v1-user.username."
24-
kvKeyEmailPrefix = "v1-user.email."
25-
kvKeyAlternateEmailsPrefix = "v1-merged-user.alternate-emails."
26-
kvKeyPrimaryEmailByUserSfid = "v1-user.primary-email."
23+
kvKeyUsernamePrefix = "v1-user.username."
24+
kvKeyEmailPrefix = "v1-user.email."
25+
kvKeyAlternateEmailsPrefix = "v1-merged-user.alternate-emails."
2726

2827
// v1-objects KV key prefixes as replicated by Meltano.
2928
v1MergedUserKVPrefix = "salesforce-merged_user."
@@ -57,10 +56,7 @@ var (
5756

5857
// handleMergedUserDelete dependencies, split out so tests can inject fakes
5958
// without needing a live NATS connection.
60-
var (
61-
getPrimaryEmailForUserFn = getCachedPrimaryEmailForUser
62-
publishUserDeletedEventFn = publishUserDeletedEvent
63-
)
59+
var publishUserDeletedEventFn = publishUserDeletedEvent
6460

6561
// toKVKey normalizes a user-provided string and encodes it as a URL-safe base64
6662
// key segment safe for NATS KV. Order: TrimSpace → ToLower → NFC → RawURLEncoding.
@@ -156,18 +152,7 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma
156152
// without its alternate emails being deleted first, those entries will be orphaned.
157153

158154
if username != "" {
159-
email, emailErr := getPrimaryEmailForUserFn(ctx, userSfid)
160-
if emailErr != nil {
161-
logger.With(errKey, emailErr, "key", key, "user_sfid", userSfid).
162-
WarnContext(ctx, "failed to look up primary email for deleted user; committee username scrub skipped")
163-
} else {
164-
publishUserDeletedEventFn(ctx, key, username, email)
165-
// Best-effort cleanup of the cached primary-email key.
166-
if err := deleteIndexKeyFn(ctx, kvKeyPrimaryEmailByUserSfid+userSfid); err != nil {
167-
logger.With(errKey, err, "key", key, "user_sfid", userSfid).
168-
WarnContext(ctx, "failed to delete cached primary email after user-deleted event")
169-
}
170-
}
155+
publishUserDeletedEventFn(ctx, key, username)
171156
}
172157

173158
return false
@@ -178,15 +163,14 @@ func handleMergedUserDelete(ctx context.Context, key, userSfid string, v1Data ma
178163
// the username from committee members and settings writers/auditors.
179164
type userDeletedEvent struct {
180165
Username string `json:"username"`
181-
Email string `json:"email"`
182166
}
183167

184168
const v1SyncHelperUserDeletedSubject = "lfx.v1-sync-helper.user.deleted"
185169

186170
// publishUserDeletedEvent publishes a user-deleted NATS event. Best-effort: publish
187171
// errors are logged and do not affect the delete handler's return value.
188-
func publishUserDeletedEvent(ctx context.Context, key, username, email string) {
189-
payload, err := json.Marshal(userDeletedEvent{Username: username, Email: email})
172+
func publishUserDeletedEvent(ctx context.Context, key, username string) {
173+
payload, err := json.Marshal(userDeletedEvent{Username: username})
190174
if err != nil {
191175
logger.With(errKey, err, "key", key).
192176
ErrorContext(ctx, "failed to marshal user-deleted event; committee username scrub skipped")
@@ -201,21 +185,6 @@ func publishUserDeletedEvent(ctx context.Context, key, username, email string) {
201185
InfoContext(ctx, "published user-deleted event for committee username scrub")
202186
}
203187

204-
// getCachedPrimaryEmailForUser returns the primary email for a user. It first
205-
// checks the v1-mappings cache written by handleAlternateEmailUpdate (which
206-
// survives alternate-email row deletion), then falls back to the live KV
207-
// lookup. The cache avoids an ordering problem where alternate-email rows are
208-
// cleaned up before the merged-user deletion event is processed.
209-
func getCachedPrimaryEmailForUser(ctx context.Context, userSfid string) (string, error) {
210-
cacheKey := kvKeyPrimaryEmailByUserSfid + userSfid
211-
if entry, err := mappingsKV.Get(ctx, cacheKey); err == nil {
212-
if email := strings.TrimSpace(string(entry.Value())); email != "" {
213-
return email, nil
214-
}
215-
}
216-
return getPrimaryEmailForUser(ctx, userSfid)
217-
}
218-
219188
// syncMergedUserProfile calls syncProfileToAuth0Fn synchronously and returns
220189
// true if the error is retryable so the caller can NACK the JetStream message.
221190
func syncMergedUserProfile(ctx context.Context, key, auth0UserID string, v1Data map[string]any) bool {
@@ -292,18 +261,8 @@ func handleAlternateEmailUpdate(ctx context.Context, key string, v1Data map[stri
292261
}
293262
}
294263

295-
// Primary emails are not linked as Auth0 identities (they are the Auth0
296-
// user's own email). Cache the address in mappings so handleMergedUserDelete
297-
// can supply it to the committee scrub even after the alternate-email rows
298-
// have been cleaned up ahead of the merged-user row.
264+
// Primary emails are not linked as Auth0 identities (they are the Auth0 user's own email).
299265
if isPrimary, _ := v1Data["primary_email__c"].(bool); isPrimary {
300-
if emailAddr != "" {
301-
cacheKey := kvKeyPrimaryEmailByUserSfid + leadorcontactid
302-
if _, err := mappingsKV.Put(ctx, cacheKey, []byte(emailAddr)); err != nil {
303-
logger.With(errKey, err, "key", key, "user_sfid", leadorcontactid).
304-
WarnContext(ctx, "failed to cache primary email for user deletion scrub")
305-
}
306-
}
307266
return false
308267
}
309268

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

Lines changed: 6 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -325,12 +325,10 @@ func TestExtractUsernameIndex(t *testing.T) {
325325
func TestHandleMergedUserDeleteScrub(t *testing.T) {
326326
origLogger := logger
327327
origDeleteIndex := deleteIndexKeyFn
328-
origGetEmail := getPrimaryEmailForUserFn
329328
origPublish := publishUserDeletedEventFn
330329
t.Cleanup(func() {
331330
logger = origLogger
332331
deleteIndexKeyFn = origDeleteIndex
333-
getPrimaryEmailForUserFn = origGetEmail
334332
publishUserDeletedEventFn = origPublish
335333
})
336334
logger = slog.New(slog.NewTextHandler(io.Discard, nil))
@@ -339,26 +337,22 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) {
339337
const (
340338
userSfid = "003ABC"
341339
username = "alice"
342-
email = "alice@example.com"
343340
)
344341

345342
tests := []struct {
346343
name string
347344
v1Data map[string]any
348-
emailErr error
349345
wantPublished bool
350346
wantUsername string
351-
wantEmail string
352347
}{
353348
{
354-
name: "username present, email resolved → publish event",
349+
name: "username present → publish event",
355350
v1Data: map[string]any{
356-
"sfid": userSfid,
351+
"sfid": userSfid,
357352
"username__c": username,
358353
},
359354
wantPublished: true,
360355
wantUsername: username,
361-
wantEmail: email,
362356
},
363357
{
364358
name: "no username → no publish",
@@ -367,15 +361,6 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) {
367361
},
368362
wantPublished: false,
369363
},
370-
{
371-
name: "email lookup fails → no publish",
372-
v1Data: map[string]any{
373-
"sfid": userSfid,
374-
"username__c": username,
375-
},
376-
emailErr: errors.New("email lookup failed"),
377-
wantPublished: false,
378-
},
379364
{
380365
name: "nil v1Data (hard KV delete) → no publish",
381366
v1Data: nil,
@@ -385,19 +370,12 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) {
385370

386371
for _, tc := range tests {
387372
t.Run(tc.name, func(t *testing.T) {
388-
var publishedUsername, publishedEmail string
373+
var publishedUsername string
389374
var publishCalled bool
390375

391-
getPrimaryEmailForUserFn = func(_ context.Context, _ string) (string, error) {
392-
if tc.emailErr != nil {
393-
return "", tc.emailErr
394-
}
395-
return email, nil
396-
}
397-
publishUserDeletedEventFn = func(_ context.Context, _, u, e string) {
376+
publishUserDeletedEventFn = func(_ context.Context, _, u string) {
398377
publishCalled = true
399378
publishedUsername = u
400-
publishedEmail = e
401379
}
402380

403381
got := handleMergedUserDelete(context.Background(), "test-key", userSfid, tc.v1Data)
@@ -408,13 +386,8 @@ func TestHandleMergedUserDeleteScrub(t *testing.T) {
408386
if publishCalled != tc.wantPublished {
409387
t.Errorf("publishCalled = %v, want %v", publishCalled, tc.wantPublished)
410388
}
411-
if tc.wantPublished {
412-
if publishedUsername != tc.wantUsername {
413-
t.Errorf("published username = %q, want %q", publishedUsername, tc.wantUsername)
414-
}
415-
if publishedEmail != tc.wantEmail {
416-
t.Errorf("published email = %q, want %q", publishedEmail, tc.wantEmail)
417-
}
389+
if tc.wantPublished && publishedUsername != tc.wantUsername {
390+
t.Errorf("published username = %q, want %q", publishedUsername, tc.wantUsername)
418391
}
419392
})
420393
}

0 commit comments

Comments
 (0)