Skip to content

Commit d8439e3

Browse files
authored
fix: prevent locking before setting new user data (#751)
1 parent 96b0893 commit d8439e3

File tree

5 files changed

+195
-55
lines changed

5 files changed

+195
-55
lines changed
Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { SegmentClient } from '../../analytics';
2-
import { getMockLogger } from '../__helpers__/mockLogger';
3-
import { MockSegmentStore } from '../__helpers__/mockSegmentStore';
1+
import { EventType, SegmentEvent } from '../../types';
2+
import { createTestClient } from '../__helpers__/setupSegmentClient';
43

54
jest.mock('../../uuid');
65

@@ -9,41 +8,30 @@ jest
98
.mockReturnValue('2010-01-01T00:00:00.000Z');
109

1110
describe('methods #alias', () => {
12-
const store = new MockSegmentStore({
13-
userInfo: {
14-
anonymousId: 'anonymousId',
15-
userId: 'current-user-id',
16-
},
17-
});
18-
19-
const clientArgs = {
20-
config: {
21-
writeKey: '123-456',
22-
},
23-
logger: getMockLogger(),
24-
store: store,
11+
const initialUserInfo = {
12+
anonymousId: 'anonymousId',
13+
userId: 'current-user-id',
2514
};
2615

16+
const { store, client, expectEvent } = createTestClient({
17+
userInfo: initialUserInfo,
18+
});
19+
2720
beforeEach(() => {
2821
store.reset();
2922
jest.clearAllMocks();
3023
});
3124

3225
it('adds the alias event correctly', async () => {
33-
const client = new SegmentClient(clientArgs);
34-
35-
jest.spyOn(client, 'process');
36-
3726
await client.alias('new-user-id');
3827

3928
const expectedEvent = {
4029
previousId: 'current-user-id',
41-
type: 'alias',
30+
type: EventType.AliasEvent,
4231
userId: 'new-user-id',
4332
};
4433

45-
expect(client.process).toHaveBeenCalledTimes(1);
46-
expect(client.process).toHaveBeenCalledWith(expectedEvent);
34+
expectEvent(expectedEvent);
4735

4836
expect(client.userInfo.get()).toEqual({
4937
anonymousId: 'anonymousId',
@@ -53,32 +41,45 @@ describe('methods #alias', () => {
5341
});
5442

5543
it('uses anonymousId in event if no userId in store', async () => {
56-
const client = new SegmentClient({
57-
...clientArgs,
58-
store: new MockSegmentStore({
59-
userInfo: {
60-
anonymousId: 'anonymousId',
61-
userId: undefined,
62-
},
63-
}),
44+
await store.userInfo.set({
45+
anonymousId: 'anonymousId',
46+
userId: undefined,
6447
});
65-
jest.spyOn(client, 'process');
6648

6749
await client.alias('new-user-id');
6850

6951
const expectedEvent = {
7052
previousId: 'anonymousId',
71-
type: 'alias',
53+
type: EventType.AliasEvent,
7254
userId: 'new-user-id',
7355
};
7456

75-
expect(client.process).toHaveBeenCalledTimes(1);
76-
expect(client.process).toHaveBeenCalledWith(expectedEvent);
57+
expectEvent(expectedEvent);
7758

7859
expect(client.userInfo.get()).toEqual({
7960
anonymousId: 'anonymousId',
8061
userId: 'new-user-id',
8162
traits: undefined,
8263
});
8364
});
65+
66+
it('is concurrency safe', async () => {
67+
// We trigger an alias and do not await it, we do a track immediately and await.
68+
// The track call should have the correct values injected into it.
69+
client.alias('new-user-id');
70+
await client.track('something');
71+
72+
const expectedTrackEvent: Partial<SegmentEvent> = {
73+
event: 'something',
74+
userId: 'new-user-id',
75+
type: EventType.TrackEvent,
76+
};
77+
78+
expectEvent(expectedTrackEvent);
79+
80+
expect(client.userInfo.get()).toEqual({
81+
...initialUserInfo,
82+
userId: 'new-user-id',
83+
});
84+
});
8485
});

packages/core/src/__tests__/methods/identify.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,6 @@ describe('methods #identify', () => {
5858
});
5959

6060
it('does not update userId when userId is undefined', async () => {
61-
jest.spyOn(client, 'process');
62-
6361
await client.identify(undefined, { name: 'Mary' });
6462

6563
const expectedEvent = {
@@ -110,4 +108,24 @@ describe('methods #identify', () => {
110108
userId: 'new-user-id',
111109
});
112110
});
111+
112+
it('is concurrency safe', async () => {
113+
// We trigger an identify and do not await it, we do a track immediately and await.
114+
// The track call should have the correct values injected into it.
115+
client.identify('new-user-id');
116+
await client.track('something');
117+
118+
const expectedTrackEvent: Partial<SegmentEvent> = {
119+
event: 'something',
120+
userId: 'new-user-id',
121+
type: EventType.TrackEvent,
122+
};
123+
124+
expectEvent(expectedTrackEvent);
125+
126+
expect(client.userInfo.get()).toEqual({
127+
...initialUserInfo,
128+
userId: 'new-user-id',
129+
});
130+
});
113131
});

packages/core/src/analytics.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -507,8 +507,9 @@ export class SegmentClient {
507507
}
508508

509509
async alias(newUserId: string) {
510-
const { anonymousId, userId: previousUserId } =
511-
await this.store.userInfo.get(true);
510+
// We don't use a concurrency safe version of get here as we don't want to lock the values yet,
511+
// we will update the values correctly when InjectUserInfo processes the change
512+
const { anonymousId, userId: previousUserId } = this.store.userInfo.get();
512513

513514
const event = createAliasEvent({
514515
anonymousId,

packages/core/src/plugins/InjectUserInfo.ts

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,12 @@ export class InjectUserInfo extends PlatformPlugin {
1414
type = PluginType.before;
1515

1616
async execute(event: SegmentEvent): Promise<SegmentEvent> {
17-
const userInfo = await this.analytics!.userInfo.get(true);
18-
const injectedEvent: SegmentEvent = {
19-
...event,
20-
anonymousId: userInfo.anonymousId,
21-
userId: userInfo.userId,
22-
};
23-
17+
// Order here is IMPORTANT!
18+
// Identify and Alias userInfo set operations have to come as soon as possible
19+
// Do not block the set by doing a safe get first as it might cause a race condition
20+
// within events procesing in the timeline asyncronously
2421
if (event.type === EventType.IdentifyEvent) {
25-
await this.analytics!.userInfo.set((state) => ({
22+
const userInfo = await this.analytics!.userInfo.set((state) => ({
2623
...state,
2724
userId: event.userId ?? state.userId,
2825
traits: {
@@ -32,27 +29,41 @@ export class InjectUserInfo extends PlatformPlugin {
3229
}));
3330

3431
return {
35-
...injectedEvent,
32+
...event,
33+
anonymousId: userInfo.anonymousId,
3634
userId: event.userId ?? userInfo.userId,
3735
traits: {
3836
...userInfo.traits,
3937
...event.traits,
4038
},
4139
} as IdentifyEventType;
4240
} else if (event.type === EventType.AliasEvent) {
43-
const { anonymousId, userId: previousUserId } = userInfo;
41+
let previousUserId: string;
4442

45-
await this.analytics!.userInfo.set((state) => ({
46-
...state,
47-
userId: event.userId,
48-
}));
43+
const userInfo = await this.analytics!.userInfo.set((state) => {
44+
previousUserId = state.userId ?? state.anonymousId;
45+
46+
return {
47+
...state,
48+
userId: event.userId,
49+
};
50+
});
4951

5052
return {
51-
...injectedEvent,
53+
...event,
54+
anonymousId: userInfo.anonymousId,
5255
userId: event.userId,
53-
previousId: previousUserId || anonymousId,
56+
previousId: previousUserId!,
5457
} as AliasEventType;
5558
}
59+
60+
const userInfo = await this.analytics!.userInfo.get(true);
61+
const injectedEvent: SegmentEvent = {
62+
...event,
63+
anonymousId: userInfo.anonymousId,
64+
userId: userInfo.userId,
65+
};
66+
5667
return injectedEvent;
5768
}
5869
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { createIdentifyEvent, createTrackEvent } from '../../events';
2+
import { type SegmentEvent, EventType, UserTraits } from '../../types';
3+
import { createTestClient } from '../../__tests__/__helpers__/setupSegmentClient';
4+
import { InjectUserInfo } from '../InjectUserInfo';
5+
6+
describe('InjectContext', () => {
7+
const currentUserId = 'current-user-id';
8+
const anonymousId = 'very-anonymous';
9+
10+
const initialUserInfo = {
11+
traits: {
12+
name: 'Stacy',
13+
age: 30,
14+
},
15+
userId: currentUserId,
16+
anonymousId: anonymousId,
17+
};
18+
const { store, client } = createTestClient({
19+
userInfo: initialUserInfo,
20+
});
21+
22+
beforeEach(() => {
23+
store.reset();
24+
jest.clearAllMocks();
25+
});
26+
27+
it('adds userId and anonymousId to events', async () => {
28+
const expectedEvent: Partial<SegmentEvent> = {
29+
event: 'something',
30+
userId: currentUserId,
31+
anonymousId: anonymousId,
32+
type: EventType.TrackEvent,
33+
};
34+
35+
const plugin = new InjectUserInfo();
36+
plugin.configure(client);
37+
const event = await plugin.execute(
38+
createTrackEvent({
39+
event: 'something',
40+
})
41+
);
42+
43+
expect(event).toMatchObject(expectedEvent);
44+
});
45+
46+
it('updates userId and traits on identify', async () => {
47+
const newUserId = 'new-user-id';
48+
const newTraits: UserTraits = {
49+
age: 30,
50+
};
51+
52+
const expectedEvent: Partial<SegmentEvent> = {
53+
userId: newUserId,
54+
anonymousId: anonymousId,
55+
traits: newTraits,
56+
type: EventType.IdentifyEvent,
57+
};
58+
59+
const plugin = new InjectUserInfo();
60+
plugin.configure(client);
61+
const event = await plugin.execute(
62+
createIdentifyEvent({
63+
userId: newUserId,
64+
userTraits: newTraits,
65+
})
66+
);
67+
68+
expect(event).toMatchObject(expectedEvent);
69+
expect(client.userInfo.get()).toEqual({
70+
...initialUserInfo,
71+
userId: 'new-user-id',
72+
});
73+
});
74+
75+
it('is concurrency safe', async () => {
76+
const newUserId = 'new-user-id';
77+
const newTraits: UserTraits = {
78+
age: 30,
79+
};
80+
81+
const expectedEvent: Partial<SegmentEvent> = {
82+
userId: newUserId,
83+
anonymousId: anonymousId,
84+
type: EventType.TrackEvent,
85+
};
86+
87+
const plugin = new InjectUserInfo();
88+
plugin.configure(client);
89+
90+
plugin.execute(
91+
createIdentifyEvent({
92+
userId: newUserId,
93+
userTraits: newTraits,
94+
})
95+
);
96+
97+
const event = await plugin.execute(
98+
createTrackEvent({
99+
event: 'something',
100+
})
101+
);
102+
103+
expect(event).toMatchObject(expectedEvent);
104+
expect(client.userInfo.get()).toEqual({
105+
...initialUserInfo,
106+
userId: 'new-user-id',
107+
});
108+
});
109+
});

0 commit comments

Comments
 (0)