-
Notifications
You must be signed in to change notification settings - Fork 1.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Bug Fix: Stop making duplicate time series requests #6529
base: master
Are you sure you want to change the base?
Changes from 1 commit
cc1bbaf
f79b718
94b102a
5814618
20bf2f0
4d264a5
3eea398
0d741af
02ee0fd
bcd88ae
11e7729
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -19,6 +19,7 @@ import {forkJoin, merge, Observable, of} from 'rxjs'; | |
import { | ||
catchError, | ||
throttleTime, | ||
combineLatestWith, | ||
filter, | ||
map, | ||
mergeMap, | ||
|
@@ -68,6 +69,12 @@ const getCardFetchInfo = createSelector( | |
|
||
const initAction = createAction('[Metrics Effects] Init'); | ||
|
||
function parseRunIdFromSampledRunInfoName(eidRun: string): string { | ||
if (!eidRun) return ''; | ||
const [, ...runIdChunks] = eidRun.split('/'); | ||
return runIdChunks.join('/'); | ||
} | ||
|
||
@Injectable() | ||
export class MetricsEffects implements OnInitEffects { | ||
constructor( | ||
|
@@ -76,6 +83,40 @@ export class MetricsEffects implements OnInitEffects { | |
private readonly dataSource: MetricsDataSource | ||
) {} | ||
|
||
readonly tagToEid$: Observable<Record<string, Set<string>>> = this.store | ||
.select(selectors.getMetricsTagMetadata) | ||
.pipe( | ||
combineLatestWith(this.store.select(selectors.getRunIdToExperimentId)), | ||
map(([tagMetadata, runToEid]) => { | ||
const imageTagToRuns = Object.fromEntries( | ||
Object.entries(tagMetadata.images.tagRunSampledInfo).map( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we handle plugins and sampled vs non-sampled more generically? Ideally the code is unaware of the set of plugin types (it doesn't know about images, scalars, or histograms). Ideally the code is unaware of which plugin types are sampled and which are not. There is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alright, I thought the additional loop that approach required harmed readability a bit but I've gone ahead and refactored to use it. |
||
([tag, sampledRunInfo]) => { | ||
const runIds = Object.keys(sampledRunInfo).map((runInfoKey) => | ||
parseRunIdFromSampledRunInfoName(runInfoKey) | ||
); | ||
return [tag, runIds]; | ||
} | ||
) | ||
); | ||
|
||
const tagToEid: Record<string, Set<string>> = {}; | ||
function mapTagsToEid(tagToRun: Record<string, readonly string[]>) { | ||
Object.entries(tagToRun).forEach(([tag, runIds]) => { | ||
if (!tagToEid[tag]) { | ||
tagToEid[tag] = new Set(); | ||
} | ||
runIds.forEach((runId) => tagToEid[tag].add(runToEid[runId])); | ||
}); | ||
} | ||
|
||
mapTagsToEid(tagMetadata.scalars.tagToRuns); | ||
mapTagsToEid(tagMetadata.histograms.tagToRuns); | ||
mapTagsToEid(imageTagToRuns); | ||
|
||
return tagToEid; | ||
}) | ||
); | ||
|
||
/** @export */ | ||
ngrxOnInitEffects(): Action { | ||
return initAction(); | ||
|
@@ -195,23 +236,31 @@ export class MetricsEffects implements OnInitEffects { | |
fetchInfos: CardFetchInfo[], | ||
experimentIds: string[] | ||
) { | ||
/** | ||
* TODO(psybuzz): if 2 cards require the same data, we should dedupe instead of | ||
* making 2 identical requests. | ||
*/ | ||
const requests: TimeSeriesRequest[] = fetchInfos.map((fetchInfo) => { | ||
const {plugin, tag, runId, sample} = fetchInfo; | ||
const partialRequest: TimeSeriesRequest = isSingleRunPlugin(plugin) | ||
? {plugin, tag, runId: runId!} | ||
: {plugin, tag, experimentIds}; | ||
if (sample !== undefined) { | ||
partialRequest.sample = sample; | ||
} | ||
return partialRequest; | ||
}); | ||
|
||
// Fetch and handle responses. | ||
return of(requests).pipe( | ||
return this.tagToEid$.pipe( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than piping this.tagToEid$ can we just get the latest value? I'm a little worried about subtle bugs when tagToEid$ changes for whatever unpredicatable reason and the pipe kicks off a new set of requests. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've added a |
||
map((tagToEid): TimeSeriesRequest[] => { | ||
const requests = fetchInfos.map((fetchInfo) => { | ||
const {plugin, tag, runId, sample} = fetchInfo; | ||
const filteredEids = experimentIds.filter((eid) => | ||
tagToEid[tag]?.has(eid) | ||
); | ||
|
||
const partialRequest: TimeSeriesRequest = isSingleRunPlugin(plugin) | ||
? {plugin, tag, runId: runId!} | ||
: {plugin, tag, experimentIds: filteredEids}; | ||
if (sample !== undefined) { | ||
partialRequest.sample = sample; | ||
} | ||
return partialRequest; | ||
}); | ||
const uniqueRequests = new Set( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there an actual problem you are trying to solve here? I don't see a test for this and I didn't see anything about it in the PR description. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am attempting to address the TODO left by psybuzz There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does "if 2 cards require the same data" happen in practice? If it does, is it a source of problems? If not, especially given that this code is critical, do we need to be making unnecessary changes? Also, it's not clear to me that you wrote a test for this particular change? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was an existing test which verified this was doing the wrong thing which I updated. See the comment that I removed from line 375 of metrics_effects_test |
||
requests.map((request) => JSON.stringify(request)) | ||
); | ||
|
||
return Array.from(uniqueRequests).map( | ||
(serialized) => JSON.parse(serialized) as TimeSeriesRequest | ||
); | ||
}), | ||
tap((requests) => { | ||
this.store.dispatch(actions.multipleTimeSeriesRequested({requests})); | ||
}), | ||
|
@@ -302,4 +351,5 @@ export class MetricsEffects implements OnInitEffects { | |
export const TEST_ONLY = { | ||
getCardFetchInfo, | ||
initAction, | ||
parseRunIdFromSampledRunInfoName, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -89,8 +89,52 @@ describe('metrics effects', () => { | |
selectors.getMetricsTooltipSort, | ||
TooltipSort.ALPHABETICAL | ||
); | ||
|
||
overrideTagMetadata(); | ||
overrideRunToEid(); | ||
}); | ||
|
||
function overrideTagMetadata() { | ||
store.overrideSelector(selectors.getMetricsTagMetadata, { | ||
scalars: { | ||
tagDescriptions: {} as any, | ||
tagToRuns: { | ||
tagA: ['run1'], | ||
bmd3k marked this conversation as resolved.
Show resolved
Hide resolved
|
||
tagB: ['run2', 'run3'], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are no references to 'run2' thru 'run6' or 'tagC' and 'tagD' or 'defaultExperimentId' anywhere else in this test as far as I can tell. Maybe just mock the minimum amount of data you need for existing tests to pass and override at a more detailed level only for your new tests? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added some additional data to the state to ensure it did not result in additional requests. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you add some comments acknowledging which data is unnecessary and why you include it? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After your last comment I added some additional tests and ensured that all of the data is being used. In particular the test |
||
tagC: ['run4', 'run5'], | ||
tagD: ['run6'], | ||
}, | ||
}, | ||
histograms: { | ||
tagDescriptions: {} as any, | ||
tagToRuns: { | ||
tagA: ['run1'], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it valid for there to be duplicate tags across scalars/histograms? Doesn't seem to make sense to me. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there anything that prohibits this? Tags can appear in multiple experiments and they could have different data being logged. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah ya, fair enough. That makes sense. |
||
tagB: ['run4'], | ||
}, | ||
}, | ||
images: { | ||
tagDescriptions: {}, | ||
tagRunSampledInfo: { | ||
tagC: { | ||
'defaultExperimentId/run1': {} as any, | ||
'exp1/run3': {} as any, | ||
}, | ||
}, | ||
}, | ||
}); | ||
} | ||
|
||
function overrideRunToEid() { | ||
store.overrideSelector(selectors.getRunIdToExperimentId, { | ||
run1: 'exp1', | ||
run2: 'exp1', | ||
run3: 'exp2', | ||
run4: 'defaultExperimentId', | ||
run5: 'defaultExperimentId', | ||
run6: 'defaultExperimentId', | ||
}); | ||
} | ||
|
||
afterEach(() => { | ||
store?.resetSelectors(); | ||
}); | ||
|
@@ -365,35 +409,25 @@ describe('metrics effects', () => { | |
actions$.next(reloadAction()); | ||
|
||
expect(fetchTagMetadataSpy).toHaveBeenCalled(); | ||
expect(fetchTimeSeriesSpy).toHaveBeenCalledTimes(2); | ||
expect(fetchTimeSeriesSpy).toHaveBeenCalledTimes(1); | ||
expect(actualActions).toEqual([ | ||
actions.metricsTagMetadataRequested(), | ||
actions.metricsTagMetadataLoaded({ | ||
tagMetadata: buildDataSourceTagMetadata(), | ||
}), | ||
|
||
// Currently we expect 2x the same requests if the cards are the same. | ||
// Ideally we should dedupe requests for the same info. | ||
actions.multipleTimeSeriesRequested({ | ||
requests: [ | ||
{ | ||
plugin: PluginType.SCALARS as MultiRunPluginType, | ||
tag: 'tagA', | ||
experimentIds: ['exp1'], | ||
}, | ||
{ | ||
plugin: PluginType.SCALARS as MultiRunPluginType, | ||
tag: 'tagA', | ||
experimentIds: ['exp1'], | ||
}, | ||
], | ||
}), | ||
actions.fetchTimeSeriesLoaded({ | ||
response: buildTimeSeriesResponse(), | ||
}), | ||
actions.fetchTimeSeriesLoaded({ | ||
response: buildTimeSeriesResponse(), | ||
}), | ||
]); | ||
}); | ||
|
||
|
@@ -487,6 +521,8 @@ describe('metrics effects', () => { | |
it('does not re-fetch time series, until a valid experiment id', () => { | ||
// Reset any `getExperimentIdsFromRoute` overrides above. | ||
store.resetSelectors(); | ||
overrideTagMetadata(); | ||
overrideRunToEid(); | ||
store.overrideSelector(getActivePlugin, METRICS_PLUGIN_ID); | ||
store.overrideSelector( | ||
selectors.getVisibleCardIdSet, | ||
|
@@ -510,6 +546,43 @@ describe('metrics effects', () => { | |
|
||
expect(fetchTimeSeriesSpy).toHaveBeenCalledTimes(2); | ||
}); | ||
|
||
it('does not send requests to experiments lacking a cards tag', () => { | ||
store.overrideSelector(getActivePlugin, METRICS_PLUGIN_ID); | ||
store.overrideSelector(selectors.getExperimentIdsFromRoute, [ | ||
'exp1', | ||
'exp2', | ||
]); | ||
store.overrideSelector( | ||
selectors.getVisibleCardIdSet, | ||
new Set(['card1', 'card2']) | ||
); | ||
provideCardFetchInfo([ | ||
{id: 'card1', tag: 'tagA'}, | ||
{id: 'card2', tag: 'tagB'}, | ||
]); | ||
store.refreshState(); | ||
|
||
const effectFetchTimeSeriesSpy = spyOn( | ||
effects as any, | ||
'fetchTimeSeries' | ||
).and.stub(); | ||
|
||
actions$.next(coreActions.manualReload()); | ||
|
||
expect(effectFetchTimeSeriesSpy).toHaveBeenCalledTimes(2); | ||
expect(effectFetchTimeSeriesSpy).toHaveBeenCalledWith({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's extremely hard to reason why the test concludes that these should be the requests sent. Some of the key test data (like in overrideTagMetadata and overrideRunToEid) are setup far from here. Maybe it would be helpful to leave a comment about all the requests that could have been made and identify why certain requests were filtered out. |
||
plugin: 'scalars', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we also be fetching plugin: 'histograms', tag: 'tagA', experimentIds: ['exp1']? |
||
tag: 'tagA', | ||
experimentIds: ['exp1'], | ||
}); | ||
|
||
expect(effectFetchTimeSeriesSpy).toHaveBeenCalledWith({ | ||
plugin: 'scalars', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Given the complexity of the logic you've written it would be good to see more rigourous testing. A single test case doesn't really seem to cut it. A couple that pop up in my head:
|
||
tag: 'tagB', | ||
experimentIds: ['exp1', 'exp2'], | ||
}); | ||
}); | ||
}); | ||
|
||
describe('loadTimeSeriesForVisibleCardsWithoutData', () => { | ||
|
@@ -778,4 +851,24 @@ describe('metrics effects', () => { | |
} | ||
}); | ||
}); | ||
|
||
describe('#utilities', () => { | ||
describe('parseRunIdFromSampledRunInfoName', () => { | ||
it('removes prefixed experiment id', () => { | ||
expect( | ||
TEST_ONLY.parseRunIdFromSampledRunInfoName('experimentId/someRun') | ||
).toEqual('someRun'); | ||
}); | ||
|
||
it('preserves "/" characters in run names', () => { | ||
expect( | ||
TEST_ONLY.parseRunIdFromSampledRunInfoName('experimentId/some/run') | ||
).toEqual('some/run'); | ||
}); | ||
|
||
it('returns an empty string when an empty string is provided', () => { | ||
expect(TEST_ONLY.parseRunIdFromSampledRunInfoName('')).toEqual(''); | ||
}); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand what you are trying to parse here. A comment would be helpful.
Is it this part highlighted in red:
Is this the same format as the run names for tagMetadata.scalars.tagToRuns and tagMetadata.histograms.tagToRuns? If so, why handle it differently for this case?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've found a way to avoid doing this parsing but the structure still needs to be different.
I'll add a block comment explaining this.
The structure of
SampledTagMetadata
is quite different from non sampledThe NonSampledPlugins map from
run
totag
while the SampledPlugin(s) map fromtag
torun
Sampled
Non Sampled
Rough Sketch
Here is a rough sketch with only the relevant parts