Skip to content

Commit 66b5e6e

Browse files
jvigliottaakhenry
authored andcommitted
[Telemetry API] Prevent Subscriptions with different options from overwriting each other (#7930)
* initial implementation * cleaning up a bit * adding the hash method back as we dont want gigantic keys * adding a line * added filtering to state generator, updated filters readme to fix error, more robust hash function * removing unnecessary changes in wrong file * adding a test to confirm each endpoint has a separate subscription based of filtering * lint * adding back in hints, accidentally removed * remove some redundant code and convert sanitization method into a replacer function for stringify * tweaking serialize replacer to handle arrays correctly, adding more determinative row addition check to test * more focused selector for the table * simplified the serialization method even further and added some more docs
1 parent 860c13d commit 66b5e6e

File tree

5 files changed

+267
-12
lines changed

5 files changed

+267
-12
lines changed

e2e/tests/functional/plugins/displayLayout/displayLayout.e2e.spec.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,8 +507,153 @@ test.describe('Display Layout', () => {
507507
// In real time mode, we don't fetch annotations at all
508508
await expect.poll(() => networkRequests, { timeout: 10000 }).toHaveLength(0);
509509
});
510+
511+
test('Same objects with different request options have unique subscriptions', async ({
512+
page
513+
}) => {
514+
// Expand My Items
515+
await page.getByLabel('Expand My Items folder').click();
516+
517+
// Create a Display Layout
518+
const displayLayout = await createDomainObjectWithDefaults(page, {
519+
type: 'Display Layout',
520+
name: 'Test Display'
521+
});
522+
523+
// Create a State Generator, set to higher frequency updates
524+
const stateGenerator = await createDomainObjectWithDefaults(page, {
525+
type: 'State Generator',
526+
name: 'State Generator'
527+
});
528+
const stateGeneratorTreeItem = page.getByRole('treeitem', {
529+
name: stateGenerator.name
530+
});
531+
await stateGeneratorTreeItem.click({ button: 'right' });
532+
await page.getByLabel('Edit Properties...').click();
533+
await page.getByLabel('State Duration (seconds)', { exact: true }).fill('0.1');
534+
await page.getByLabel('Save').click();
535+
536+
// Create a Table for filtering ON values
537+
const tableFilterOnValue = await createDomainObjectWithDefaults(page, {
538+
type: 'Telemetry Table',
539+
name: 'Table Filter On Value'
540+
});
541+
const tableFilterOnTreeItem = page.getByRole('treeitem', {
542+
name: tableFilterOnValue.name
543+
});
544+
545+
// Create a Table for filtering OFF values
546+
const tableFilterOffValue = await createDomainObjectWithDefaults(page, {
547+
type: 'Telemetry Table',
548+
name: 'Table Filter Off Value'
549+
});
550+
const tableFilterOffTreeItem = page.getByRole('treeitem', {
551+
name: tableFilterOffValue.name
552+
});
553+
554+
// Navigate to ON filtering table and add state generator and setup filters
555+
await page.goto(tableFilterOnValue.url);
556+
await stateGeneratorTreeItem.dragTo(page.getByLabel('Object View'));
557+
await selectFilterOption(page, '1');
558+
await page.getByLabel('Save').click();
559+
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
560+
561+
// Navigate to OFF filtering table and add state generator and setup filters
562+
await page.goto(tableFilterOffValue.url);
563+
await stateGeneratorTreeItem.dragTo(page.getByLabel('Object View'));
564+
await selectFilterOption(page, '0');
565+
await page.getByLabel('Save').click();
566+
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
567+
568+
// Navigate to the display layout and edit it
569+
await page.goto(displayLayout.url);
570+
571+
// Add the tables to the display layout
572+
await page.getByLabel('Edit Object').click();
573+
await tableFilterOffTreeItem.dragTo(page.getByLabel('Layout Grid'), {
574+
targetPosition: { x: 10, y: 300 }
575+
});
576+
await page.locator('.c-frame-edit > div:nth-child(4)').dragTo(page.getByLabel('Layout Grid'), {
577+
targetPosition: { x: 400, y: 500 },
578+
// eslint-disable-next-line playwright/no-force-option
579+
force: true
580+
});
581+
await tableFilterOnTreeItem.dragTo(page.getByLabel('Layout Grid'), {
582+
targetPosition: { x: 10, y: 100 }
583+
});
584+
await page.locator('.c-frame-edit > div:nth-child(4)').dragTo(page.getByLabel('Layout Grid'), {
585+
targetPosition: { x: 400, y: 300 },
586+
// eslint-disable-next-line playwright/no-force-option
587+
force: true
588+
});
589+
await page.getByLabel('Save').click();
590+
await page.getByRole('listitem', { name: 'Save and Finish Editing' }).click();
591+
592+
// Get the tables so we can verify filtering is working as expected
593+
const tableFilterOn = page.getByLabel(`${tableFilterOnValue.name} Frame`, {
594+
exact: true
595+
});
596+
const tableFilterOff = page.getByLabel(`${tableFilterOffValue.name} Frame`, {
597+
exact: true
598+
});
599+
600+
// Verify filtering is working correctly
601+
602+
// Create a promise that resolves when we've seen enough new rows added
603+
const rowsMutationPromise = page.evaluate(() => {
604+
return new Promise((resolve) => {
605+
const targetTable = document.querySelector(
606+
'table[aria-label="Table Filter Off Value table content"]'
607+
);
608+
const config = { childList: true, subtree: true };
609+
let changeCount = 0;
610+
const requiredChanges = 20; // Number of changes to wait for
611+
612+
const observer = new MutationObserver((mutations) => {
613+
mutations.forEach((mutation) => {
614+
if (mutation.type === 'childList') {
615+
// Count added nodes
616+
changeCount += mutation.addedNodes.length;
617+
}
618+
});
619+
620+
// Check if the required number of changes has been met
621+
if (changeCount >= requiredChanges) {
622+
observer.disconnect(); // Disconnect observer after the required changes
623+
resolve();
624+
}
625+
});
626+
627+
observer.observe(targetTable, config);
628+
});
629+
});
630+
631+
await rowsMutationPromise;
632+
633+
// Check ON table doesn't have any OFF values
634+
await expect(tableFilterOn.locator('td[title="OFF"]')).toHaveCount(0);
635+
const onCount = await tableFilterOn.locator('td[title="ON"]').count();
636+
await expect(onCount).toBeGreaterThan(0);
637+
638+
// Check OFF table doesn't have any ON values
639+
await expect(tableFilterOff.locator('td[title="ON"]')).toHaveCount(0);
640+
const offCount = await tableFilterOff.locator('td[title="OFF"]').count();
641+
await expect(offCount).toBeGreaterThan(0);
642+
});
510643
});
511644

645+
async function selectFilterOption(page, filterOption) {
646+
await page.getByRole('tab', { name: 'Filters' }).click();
647+
await page
648+
.getByLabel('Inspector Views')
649+
.locator('li')
650+
.filter({ hasText: 'State Generator' })
651+
.locator('span')
652+
.click();
653+
await page.getByRole('switch').click();
654+
await page.selectOption('select[name="setSelectionThreshold"]', filterOption);
655+
}
656+
512657
async function addAndRemoveDrawingObjectAndAssert(page, layoutObject, DISPLAY_LAYOUT_NAME) {
513658
await expect(page.getByLabel(layoutObject, { exact: true })).toHaveCount(0);
514659
await addLayoutObject(page, DISPLAY_LAYOUT_NAME, layoutObject);

example/generator/GeneratorMetadataProvider.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,16 @@ const METADATA_BY_TYPE = {
108108
string: 'ON'
109109
}
110110
],
111+
filters: [
112+
{
113+
singleSelectionThreshold: true,
114+
comparator: 'equals',
115+
possibleValues: [
116+
{ label: 'OFF', value: 0 },
117+
{ label: 'ON', value: 1 }
118+
]
119+
}
120+
],
111121
hints: {
112122
range: 1
113123
}

example/generator/StateGeneratorProvider.js

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,16 @@ StateGeneratorProvider.prototype.supportsSubscribe = function (domainObject) {
3434
return domainObject.type === 'example.state-generator';
3535
};
3636

37-
StateGeneratorProvider.prototype.subscribe = function (domainObject, callback) {
37+
StateGeneratorProvider.prototype.subscribe = function (domainObject, callback, options) {
3838
var duration = domainObject.telemetry.duration * 1000;
3939

40-
var interval = setInterval(function () {
40+
var interval = setInterval(() => {
4141
var now = Date.now();
4242
var datum = pointForTimestamp(now, duration, domainObject.name);
43-
datum.value = String(datum.value);
44-
callback(datum);
43+
if (!this.shouldBeFiltered(datum, options)) {
44+
datum.value = String(datum.value);
45+
callback(datum);
46+
}
4547
}, duration);
4648

4749
return function () {
@@ -63,9 +65,25 @@ StateGeneratorProvider.prototype.request = function (domainObject, options) {
6365

6466
var data = [];
6567
while (start <= end && data.length < 5000) {
66-
data.push(pointForTimestamp(start, duration, domainObject.name));
68+
const point = pointForTimestamp(start, duration, domainObject.name);
69+
70+
if (!this.shouldBeFiltered(point, options)) {
71+
data.push(point);
72+
}
6773
start += duration;
6874
}
6975

7076
return Promise.resolve(data);
7177
};
78+
79+
StateGeneratorProvider.prototype.shouldBeFiltered = function (point, options) {
80+
const valueToFilter = options?.filters?.state?.equals?.[0];
81+
82+
if (!valueToFilter) {
83+
return false;
84+
}
85+
86+
const { value } = point;
87+
88+
return value !== Number(valueToFilter);
89+
};

src/api/telemetry/TelemetryAPI.js

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,90 @@ export default class TelemetryAPI {
250250
return options;
251251
}
252252

253+
/**
254+
* Sanitizes objects for consistent serialization by:
255+
* 1. Removing non-plain objects (class instances) and functions
256+
* 2. Sorting object keys alphabetically to ensure consistent ordering
257+
* 3. Recursively processing nested objects
258+
*
259+
* Note: When used as a JSON.stringify replacer, this function will process objects
260+
* twice - once for the initial sorting and again when JSON.stringify processes the
261+
* sorted result. This is acceptable for small options objects, which is the
262+
* intended use case.
263+
*/
264+
sanitizeForSerialization(key, value) {
265+
// Handle null and primitives directly
266+
if (value === null || typeof value !== 'object') {
267+
return value;
268+
}
269+
270+
// Remove functions and non-plain objects by returning undefined
271+
if (typeof value === 'function' || Object.getPrototypeOf(value) !== Object.prototype) {
272+
return undefined;
273+
}
274+
275+
// Handle plain objects
276+
const sortedObject = {};
277+
const keys = Object.keys(value).sort();
278+
for (const objectKey of keys) {
279+
const itemValue = value[objectKey];
280+
const sanitizedValue = this.sanitizeForSerialization(objectKey, itemValue);
281+
sortedObject[objectKey] = sanitizedValue;
282+
}
283+
284+
return sortedObject;
285+
}
286+
287+
/**
288+
* Generates a numeric hash value for an options object. The hash is consistent
289+
* for equivalent option objects regardless of property order.
290+
*
291+
* This is used to create compact, unique cache keys for telemetry subscriptions with
292+
* different options configurations. The hash function ensures that identical options
293+
* objects will always generate the same hash value, while different options objects
294+
* (even with small differences) will generate different hash values.
295+
*
296+
* @private
297+
* @param {Object} options The options object to hash
298+
* @returns {number} A positive integer hash of the options object
299+
*/
300+
#hashOptions(options) {
301+
const sanitizedOptionsString = JSON.stringify(
302+
options,
303+
this.sanitizeForSerialization.bind(this)
304+
);
305+
306+
let hash = 0;
307+
const prime = 31;
308+
const modulus = 1e9 + 9; // Large prime number
309+
310+
for (let i = 0; i < sanitizedOptionsString.length; i++) {
311+
const char = sanitizedOptionsString.charCodeAt(i);
312+
// Calculate new hash value while keeping numbers manageable
313+
hash = Math.floor((hash * prime + char) % modulus);
314+
}
315+
316+
return Math.abs(hash);
317+
}
318+
319+
/**
320+
* Generates a unique cache key for a telemetry subscription based on the
321+
* domain object identifier and options (which includes strategy).
322+
*
323+
* Uses a hash of the options object to create compact cache keys while still
324+
* ensuring unique keys for different subscription configurations.
325+
*
326+
* @private
327+
* @param {import('openmct').DomainObject} domainObject The domain object being subscribed to
328+
* @param {Object} options The subscription options object (including strategy)
329+
* @returns {string} A unique key string for caching the subscription
330+
*/
331+
#getSubscriptionCacheKey(domainObject, options) {
332+
const keyString = makeKeyString(domainObject.identifier);
333+
334+
return `${keyString}:${this.#hashOptions(options)}`;
335+
}
336+
253337
/**
254338
* Register a request interceptor that transforms a request via module:openmct.TelemetryAPI.request
255339
* The request will be modified when it is received and will be returned in it's modified state
@@ -418,16 +502,14 @@ export default class TelemetryAPI {
418502
this.#subscribeCache = {};
419503
}
420504

421-
const keyString = makeKeyString(domainObject.identifier);
422505
const supportedStrategy = supportsBatching ? requestedStrategy : SUBSCRIBE_STRATEGY.LATEST;
423506
// Override the requested strategy with the strategy supported by the provider
424507
const optionsWithSupportedStrategy = {
425508
...options,
426509
strategy: supportedStrategy
427510
};
428-
// If batching is supported, we need to cache a subscription for each strategy -
429-
// latest and batched.
430-
const cacheKey = `${keyString}:${supportedStrategy}`;
511+
512+
const cacheKey = this.#getSubscriptionCacheKey(domainObject, optionsWithSupportedStrategy);
431513
let subscriber = this.#subscribeCache[cacheKey];
432514

433515
if (!subscriber) {

src/plugins/filters/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ To define a filter, you'll need to add a new `filter` property to the domain obj
2727
singleSelectionThreshold: true,
2828
comparator: 'equals',
2929
possibleValues: [
30-
{ name: 'Apple', value: 'apple' },
31-
{ name: 'Banana', value: 'banana' },
32-
{ name: 'Orange', value: 'orange' }
30+
{ label: 'Apple', value: 'apple' },
31+
{ label: 'Banana', value: 'banana' },
32+
{ label: 'Orange', value: 'orange' }
3333
]
3434
}]
3535
}

0 commit comments

Comments
 (0)