Skip to content

Commit 6c239cb

Browse files
authored
chore: Use canonical JSON for summarizer indexing. (#862)
1 parent 1e18d66 commit 6c239cb

24 files changed

+160
-412
lines changed

packages/shared/common/__tests__/Context.test.ts

Lines changed: 102 additions & 327 deletions
Large diffs are not rendered by default.

packages/shared/common/__tests__/json/canonicalize.test.ts renamed to packages/shared/common/__tests__/internal/json/canonicalize.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as fs from 'node:fs';
22
import * as path from 'node:path';
33

4-
import { canonicalize } from '../../src/json/canonicalize';
4+
import { canonicalize } from '../../../src/internal/json/canonicalize';
55

66
// Get the test file pairs
77
const testInputDir = path.join(__dirname, 'testdata', 'input');

packages/shared/common/src/Context.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/* eslint-disable no-underscore-dangle */
22
// eslint-disable-next-line max-classes-per-file
33
import type {
4-
Crypto,
54
LDContext,
65
LDContextCommon,
76
LDMultiKindContext,
@@ -10,7 +9,7 @@ import type {
109
} from './api';
1110
import AttributeReference from './AttributeReference';
1211
import { isLegacyUser, isMultiKind, isSingleKind } from './internal/context';
13-
import { canonicalize } from './json/canonicalize';
12+
import { canonicalize } from './internal/json/canonicalize';
1413
import { TypeValidators } from './validators';
1514

1615
// The general strategy for the context is to transform the passed in context
@@ -172,6 +171,8 @@ export default class Context {
172171

173172
private _privateAttributeReferences?: Record<string, AttributeReference[]>;
174173

174+
private _cachedCanonicalJson?: string;
175+
175176
public readonly kind: string;
176177

177178
/**
@@ -468,26 +469,25 @@ export default class Context {
468469
return this._wasLegacy;
469470
}
470471

471-
public async hash(crypto: Crypto): Promise<string | undefined> {
472+
/**
473+
* Get the serialized canonical JSON for this context. This is not filtered for use in events.
474+
*
475+
* This method will cache the result.
476+
*
477+
* @returns The serialized canonical JSON or undefined if it cannot be serialized.
478+
*/
479+
public canonicalUnfilteredJson(): string | undefined {
472480
if (!this.valid) {
473481
return undefined;
474482
}
475-
483+
if (this._cachedCanonicalJson) {
484+
return this._cachedCanonicalJson;
485+
}
476486
try {
477-
const canonicalized = canonicalize(this);
478-
479-
const hasher = crypto.createHash('sha256');
480-
hasher.update(canonicalized);
481-
482-
if (hasher.digest) {
483-
return hasher.digest('hex');
484-
}
485-
486-
// The hasher must have either digest or asyncDigest.
487-
const digest = await hasher.asyncDigest!('hex');
488-
return digest;
487+
this._cachedCanonicalJson = canonicalize(Context.toLDContext(this));
489488
} catch {
490-
return undefined;
489+
// Indicated by undefined being returned.
491490
}
491+
return this._cachedCanonicalJson;
492492
}
493493
}

packages/shared/common/src/internal/events/EventProcessor.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,7 @@ export default class EventProcessor implements LDEventProcessor {
154154
);
155155

156156
if (summariesPerContext) {
157-
this._summarizer = new MultiEventSummarizer(
158-
clientContext.platform.crypto,
159-
this._contextFilter,
160-
this._logger,
161-
);
157+
this._summarizer = new MultiEventSummarizer(this._contextFilter, this._logger);
162158
} else {
163159
this._summarizer = new EventSummarizer();
164160
}
@@ -232,7 +228,7 @@ export default class EventProcessor implements LDEventProcessor {
232228
this._queue = [];
233229

234230
if (isMultiEventSummarizer(this._summarizer)) {
235-
const summaries = await this._summarizer.getSummaries();
231+
const summaries = this._summarizer.getSummaries();
236232

237233
summaries.forEach((summary) => {
238234
if (Object.keys(summary.features).length) {

packages/shared/common/src/internal/events/LDEventSummarizer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export interface LDMultiEventSummarizer {
4545
* Gets the current summary of processed events.
4646
* @returns A summary of all processed feature flag events
4747
*/
48-
getSummaries(): Promise<SummarizedFlagsEvent[]>;
48+
getSummaries(): SummarizedFlagsEvent[];
4949
}
5050

5151
/**
Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1-
import { setupAsyncCrypto, setupCrypto } from '../../../__tests__/setupCrypto';
21
import { LDLogger } from '../../api';
32
import Context from '../../Context';
43
import ContextFilter from '../../ContextFilter';
54
import InputEvalEvent from './InputEvalEvent';
65
import InputIdentifyEvent from './InputIdentifyEvent';
76
import MultiEventSummarizer from './MultiEventSummarizer';
87

9-
// Test with both sync and async crypto implementations
10-
describe.each([
11-
['sync', setupCrypto()],
12-
['async', setupAsyncCrypto()],
13-
])('with mocked crypto and hasher/%s', (_name, crypto) => {
8+
// Test with both sync and crypto implementations
9+
describe('given a mock logger and an event summarizer instance', () => {
1410
let logger: LDLogger;
1511
let summarizer: MultiEventSummarizer;
1612

@@ -22,42 +18,42 @@ describe.each([
2218
info: jest.fn(),
2319
debug: jest.fn(),
2420
};
25-
summarizer = new MultiEventSummarizer(crypto, contextFilter, logger);
21+
summarizer = new MultiEventSummarizer(contextFilter, logger);
2622
});
2723

28-
it('creates new summarizer for new context hash', async () => {
24+
it('creates new summarizer for new context hash', () => {
2925
const context = Context.fromLDContext({ kind: 'user', key: 'user1' });
3026
const event = new InputEvalEvent(true, context, 'flag-key', 'value', 'default', 1, 0, true);
3127

3228
summarizer.summarizeEvent(event);
3329

34-
const summaries = await summarizer.getSummaries();
30+
const summaries = summarizer.getSummaries();
3531
expect(summaries).toHaveLength(1);
3632
});
3733

38-
it('uses existing summarizer for same context hash', async () => {
34+
it('uses existing summarizer for same context hash', () => {
3935
const context = Context.fromLDContext({ kind: 'user', key: 'user1' });
4036
const event1 = new InputEvalEvent(true, context, 'flag-key', 'value1', 'default', 1, 0, true);
4137
const event2 = new InputEvalEvent(true, context, 'flag-key', 'value2', 'default', 1, 0, true);
4238

4339
summarizer.summarizeEvent(event1);
4440
summarizer.summarizeEvent(event2);
4541

46-
const summaries = await summarizer.getSummaries();
42+
const summaries = summarizer.getSummaries();
4743
expect(summaries).toHaveLength(1);
4844
});
4945

50-
it('ignores non-feature events', async () => {
46+
it('ignores non-feature events', () => {
5147
const context = Context.fromLDContext({ kind: 'user', key: 'user1' });
5248
const event = new InputIdentifyEvent(context);
5349

5450
summarizer.summarizeEvent(event);
5551

56-
const summaries = await summarizer.getSummaries();
52+
const summaries = summarizer.getSummaries();
5753
expect(summaries).toHaveLength(0);
5854
});
5955

60-
it('handles multiple different contexts', async () => {
56+
it('handles multiple different contexts', () => {
6157
const context1 = Context.fromLDContext({ kind: 'user', key: 'user1' });
6258
const context2 = Context.fromLDContext({ kind: 'user', key: 'user2' });
6359
const event1 = new InputEvalEvent(true, context1, 'flag-key', 'value1', 'default', 1, 0, true);
@@ -66,23 +62,23 @@ describe.each([
6662
summarizer.summarizeEvent(event1);
6763
summarizer.summarizeEvent(event2);
6864

69-
const summaries = await summarizer.getSummaries();
65+
const summaries = summarizer.getSummaries();
7066
expect(summaries).toHaveLength(2);
7167
});
7268

73-
it('automatically clears summaries when summarized', async () => {
69+
it('automatically clears summaries when summarized', () => {
7470
const context = Context.fromLDContext({ kind: 'user', key: 'user1' });
7571
const event = new InputEvalEvent(true, context, 'flag-key', 'value', 'default', 1, 0, true);
7672

7773
summarizer.summarizeEvent(event);
7874

79-
const summariesA = await summarizer.getSummaries();
80-
const summariesB = await summarizer.getSummaries();
75+
const summariesA = summarizer.getSummaries();
76+
const summariesB = summarizer.getSummaries();
8177
expect(summariesA).toHaveLength(1);
8278
expect(summariesB).toHaveLength(0);
8379
});
8480

85-
it('logs error when context cannot be hashed', async () => {
81+
it('logs error when context cannot be hashed', () => {
8682
const a: any = {};
8783
const b: any = { a };
8884
a.b = b;
@@ -91,10 +87,10 @@ describe.each([
9187
const event = new InputEvalEvent(true, context, 'flag-key', 'value', 'default', 1, 0, true);
9288

9389
summarizer.summarizeEvent(event);
94-
await summarizer.getSummaries();
90+
summarizer.getSummaries();
9591
expect(logger.error).toHaveBeenCalledTimes(1);
9692
expect(logger.error).toHaveBeenCalledWith(
97-
'Unable to hash context, likely the context contains a cycle.',
93+
'Unable to serialize context, likely the context contains a cycle.',
9894
);
9995
});
10096
});

packages/shared/common/src/internal/events/MultiEventSummarizer.ts

Lines changed: 18 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Crypto, LDLogger } from '../../api';
1+
import { LDLogger } from '../../api';
22
import ContextFilter from '../../ContextFilter';
33
import EventSummarizer from './EventSummarizer';
44
import { isFeature } from './guards';
@@ -7,55 +7,34 @@ import { LDMultiEventSummarizer, SummarizedFlagsEvent } from './LDEventSummarize
77

88
export default class MultiEventSummarizer implements LDMultiEventSummarizer {
99
constructor(
10-
private readonly _crypto: Crypto,
1110
private readonly _contextFilter: ContextFilter,
1211
private readonly _logger?: LDLogger,
1312
) {}
1413
private _summarizers: Record<string, EventSummarizer> = {};
15-
private _pendingPromises: Promise<void>[] = [];
1614

1715
summarizeEvent(event: InputEvent) {
18-
// The event is summarized asynchronously, but the promise is created synchronously, this means that all events
19-
// which have been requested to be summarized will be in the next flush.
20-
const promise = (async () => {
21-
if (isFeature(event)) {
22-
const hash = await event.context.hash(this._crypto);
23-
if (!hash) {
24-
if (event.context.valid) {
25-
// The context appeared valid, but it could not be hashed.
26-
// This is likely because of a cycle in the data.
27-
this._logger?.error('Unable to hash context, likely the context contains a cycle.');
28-
}
29-
return;
16+
if (isFeature(event)) {
17+
const key = event.context.canonicalUnfilteredJson();
18+
if (!key) {
19+
if (event.context.valid) {
20+
// The context appeared valid, but it could not be hashed.
21+
// This is likely because of a cycle in the data.
22+
this._logger?.error('Unable to serialize context, likely the context contains a cycle.');
3023
}
31-
// It is important that async operations do not happen between checking that the summarizer
32-
// exists and having it summarize the event.
33-
// If it did, then that event could be lost.
34-
let summarizer = this._summarizers[hash];
35-
if (!summarizer) {
36-
this._summarizers[hash] = new EventSummarizer(true, this._contextFilter);
37-
summarizer = this._summarizers[hash];
38-
}
39-
40-
summarizer.summarizeEvent(event);
24+
return;
4125
}
42-
})();
43-
this._pendingPromises.push(promise);
44-
promise.finally(() => {
45-
const index = this._pendingPromises.indexOf(promise);
46-
if (index !== -1) {
47-
this._pendingPromises.splice(index, 1);
26+
27+
let summarizer = this._summarizers[key];
28+
if (!summarizer) {
29+
this._summarizers[key] = new EventSummarizer(true, this._contextFilter);
30+
summarizer = this._summarizers[key];
4831
}
49-
});
50-
}
5132

52-
async getSummaries(): Promise<SummarizedFlagsEvent[]> {
53-
// Wait for any pending summarizations to complete
54-
// Additional tasks queued while waiting will not be waited for.
55-
await Promise.all([...this._pendingPromises]);
33+
summarizer.summarizeEvent(event);
34+
}
35+
}
5636

57-
// It is important not to put any async operations between caching the summarizers and clearing them.
58-
// If we did then summerizers added during the async operation would be lost.
37+
getSummaries(): SummarizedFlagsEvent[] {
5938
const summarizersToFlush = this._summarizers;
6039
this._summarizers = {};
6140
return Object.values(summarizersToFlush).map((summarizer) => summarizer.getSummary());

packages/shared/common/src/internal/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export * from './events';
55
export * from './fdv2';
66
export * from './metadata';
77
export * from './plugins';
8+
export * from './json';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './canonicalize';

0 commit comments

Comments
 (0)