Skip to content

Commit 02b4bbc

Browse files
authored
[9.0] [Expression] Cached expression can keep their own side effects (#216519) (#218419)
# Backport This will backport the following commits from `main` to `9.0`: - [[Expression] Cached expression can keep their own side effects (#216519)](#216519) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Marco Liberati","email":"dej611@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-04-11T12:50:47Z","message":"[Expression] Cached expression can keep their own side effects (#216519)\n\n## Summary\n\nFixes the #207204 \n\nThis PR introduces a new complementary function for an Expression\ndefinition named `sideEffects`, this goes together with the other `fn`\nfunction and it is used to restore any side effect when the caching\nsystem kicks in.\n\n\n![side_effects_cache](https://github.com/user-attachments/assets/74b1ddff-a45c-4983-ac09-57559155fba8)\n\nI haven't found how to programmatically test this.\nWill add an FTR if it can be reliable to reproduce an expression caching\nscenario.\n\n### Checklist\n\n- [x] [Unit or functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere updated or added to match the most common scenarios\n\n### Release notes\n\nThe request inspector now shows the correct request and response in any\nsuccessful scenario.","sha":"6984530aa0ef9d3d5e93ea22f8e816cc2e222a25","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","Feature:ExpressionLanguage","Team:Visualizations","backport missing","backport:prev-minor","backport:prev-major","v9.1.0","v8.19.0","v8.18.1","v9.0.1"],"title":"[Expression] Cached expression can keep their own side effects","number":216519,"url":"https://github.com/elastic/kibana/pull/216519","mergeCommit":{"message":"[Expression] Cached expression can keep their own side effects (#216519)\n\n## Summary\n\nFixes the #207204 \n\nThis PR introduces a new complementary function for an Expression\ndefinition named `sideEffects`, this goes together with the other `fn`\nfunction and it is used to restore any side effect when the caching\nsystem kicks in.\n\n\n![side_effects_cache](https://github.com/user-attachments/assets/74b1ddff-a45c-4983-ac09-57559155fba8)\n\nI haven't found how to programmatically test this.\nWill add an FTR if it can be reliable to reproduce an expression caching\nscenario.\n\n### Checklist\n\n- [x] [Unit or functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere updated or added to match the most common scenarios\n\n### Release notes\n\nThe request inspector now shows the correct request and response in any\nsuccessful scenario.","sha":"6984530aa0ef9d3d5e93ea22f8e816cc2e222a25"}},"sourceBranch":"main","suggestedTargetBranches":["8.x","8.18","9.0"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/216519","number":216519,"mergeCommit":{"message":"[Expression] Cached expression can keep their own side effects (#216519)\n\n## Summary\n\nFixes the #207204 \n\nThis PR introduces a new complementary function for an Expression\ndefinition named `sideEffects`, this goes together with the other `fn`\nfunction and it is used to restore any side effect when the caching\nsystem kicks in.\n\n\n![side_effects_cache](https://github.com/user-attachments/assets/74b1ddff-a45c-4983-ac09-57559155fba8)\n\nI haven't found how to programmatically test this.\nWill add an FTR if it can be reliable to reproduce an expression caching\nscenario.\n\n### Checklist\n\n- [x] [Unit or functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere updated or added to match the most common scenarios\n\n### Release notes\n\nThe request inspector now shows the correct request and response in any\nsuccessful scenario.","sha":"6984530aa0ef9d3d5e93ea22f8e816cc2e222a25"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"8.18","label":"v8.18.1","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"9.0","label":"v9.0.1","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT-->
1 parent 1d91d48 commit 02b4bbc

11 files changed

Lines changed: 146 additions & 26 deletions

File tree

src/platform/plugins/shared/data/common/search/aggs/agg_configs.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,8 +472,9 @@ export class AggConfigs {
472472
if (!this.hasTimeShifts()) {
473473
return response;
474474
}
475-
const transformedRawResponse = cloneDeep(response.rawResponse);
476-
if (!transformedRawResponse.aggregations) {
475+
let transformedRawResponse = response.rawResponse;
476+
if (!response.rawResponse.aggregations) {
477+
transformedRawResponse = cloneDeep(response.rawResponse);
477478
transformedRawResponse.aggregations = {
478479
doc_count: response.rawResponse.hits?.total as estypes.AggregationsAggregate,
479480
};

src/platform/plugins/shared/data/common/search/expressions/esaggs/esaggs_fn.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ export const getEsaggsMeta: () => Omit<EsaggsExpressionFunctionDefinition, 'fn'>
5858
name,
5959
type: 'datatable',
6060
inputTypes: ['kibana_context', 'null'],
61-
allowCache: true,
6261
help: i18n.translate('data.functions.esaggs.help', {
6362
defaultMessage: 'Run AggConfig aggregation',
6463
}),

src/platform/plugins/shared/data/common/search/expressions/esql.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { getTime } from '../../query';
3232
import {
3333
ESQL_ASYNC_SEARCH_STRATEGY,
3434
ESQL_TABLE_TYPE,
35+
getSideEffectFunction,
3536
isRunningResponse,
3637
type KibanaContext,
3738
} from '..';
@@ -97,7 +98,6 @@ export const getEsqlFn = ({ getStartDependencies }: EsqlFnArguments) => {
9798
name: 'esql',
9899
type: 'datatable',
99100
inputTypes: ['kibana_context', 'null'],
100-
allowCache: true,
101101
help: i18n.translate('data.search.esql.help', {
102102
defaultMessage: 'Queries Elasticsearch using ES|QL.',
103103
}),
@@ -154,6 +154,11 @@ export const getEsqlFn = ({ getStartDependencies }: EsqlFnArguments) => {
154154
}),
155155
},
156156
},
157+
allowCache: {
158+
withSideEffects: (_, { inspectorAdapters }) => {
159+
return getSideEffectFunction(inspectorAdapters);
160+
},
161+
},
157162
fn(
158163
input,
159164
{

src/platform/plugins/shared/data/common/search/expressions/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99

1010
export * from './function_wrapper';
1111
export { adaptToExpressionValueFilter } from './filters_adapter';
12+
export { getSideEffectFunction } from './requests_side_effects';
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import { Adapters } from '@kbn/inspector-plugin/common';
11+
12+
const collectSideEffectsData = (adapters: Adapters) => {
13+
return adapters.requests?.getRequestEntries();
14+
};
15+
16+
export const getSideEffectFunction = (adapters: Adapters) => {
17+
const requestsWithResponses = collectSideEffectsData(adapters);
18+
return () => {
19+
if (!requestsWithResponses || requestsWithResponses.length === 0) {
20+
return;
21+
}
22+
const requestsMap = new Map(requestsWithResponses.map(([request]) => [request.id, request]));
23+
const responsesMap = new WeakMap(requestsWithResponses);
24+
adapters.requests?.loadFromEntries(requestsMap, responsesMap);
25+
};
26+
};

src/platform/plugins/shared/data/public/search/expressions/esaggs.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
EsaggsExpressionFunctionDefinition,
1616
EsaggsStartDependencies,
1717
getEsaggsMeta,
18+
getSideEffectFunction,
1819
} from '../../../common/search/expressions';
1920
import { DataPublicPluginStart, DataStartDependencies } from '../../types';
2021

@@ -37,13 +38,19 @@ export function getFunctionDefinition({
3738
}) {
3839
return (): EsaggsExpressionFunctionDefinition => ({
3940
...getEsaggsMeta(),
41+
allowCache: {
42+
withSideEffects: (_, { inspectorAdapters }) => {
43+
return getSideEffectFunction(inspectorAdapters);
44+
},
45+
},
4046
fn(
4147
input,
4248
args,
4349
{ inspectorAdapters, abortSignal, getSearchSessionId, getExecutionContext, getSearchContext }
4450
) {
4551
return defer(async () => {
46-
const { aggs, indexPatterns, searchSource, getNow } = await getStartDependencies();
52+
const [{ aggs, indexPatterns, searchSource, getNow }, { handleEsaggsRequest }] =
53+
await Promise.all([getStartDependencies(), import('../../../common/search/expressions')]);
4754

4855
const indexPattern = await indexPatterns.create(args.index.value, true);
4956
const aggConfigs = aggs.createAggConfigs(
@@ -57,8 +64,6 @@ export function getFunctionDefinition({
5764
}
5865
);
5966

60-
const { handleEsaggsRequest } = await import('../../../common/search/expressions');
61-
6267
return { aggConfigs, indexPattern, searchSource, getNow, handleEsaggsRequest };
6368
}).pipe(
6469
switchMap(({ aggConfigs, indexPattern, searchSource, getNow, handleEsaggsRequest }) => {

src/platform/plugins/shared/expressions/common/execution/execution.ts

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ type UnwrapReturnType<Function extends (...args: any[]) => unknown> =
5959
export interface FunctionCacheItem {
6060
value: unknown;
6161
time: number;
62+
sideEffectFn?: () => void;
6263
}
6364
/**
6465
* The result returned after an expression function execution.
@@ -475,21 +476,19 @@ export class Execution<
475476
.pipe(
476477
map((currentInput) => this.cast(currentInput, fn.inputTypes)),
477478
switchMap((normalizedInput) => {
478-
if (fn.allowCache && this.context.allowCache) {
479-
hash = calculateObjectHash([
480-
fn.name,
481-
normalizedInput,
482-
args,
483-
this.context.getSearchContext(),
484-
]);
479+
const {
480+
hash: fnHash,
481+
value: cachedValue,
482+
valid: cacheValid,
483+
} = this.#canUseCachedResult(fn, normalizedInput, args);
484+
hash = fnHash;
485+
if (cacheValid) {
486+
cachedValue.sideEffectFn?.();
487+
return of(cachedValue.value);
485488
}
486-
if (hash && this.functionCache.has(hash)) {
487-
const cached = this.functionCache.get(hash);
488-
if (cached && Date.now() - cached.time < this.cacheTimeout) {
489-
return of(cached.value);
490-
}
491-
}
492-
return of(fn.fn(normalizedInput, args, this.context));
489+
const output = fn.fn(normalizedInput, args, this.context);
490+
491+
return of(output);
493492
}),
494493
switchMap((fnResult) => {
495494
return (
@@ -524,10 +523,15 @@ export class Execution<
524523
}),
525524
finalize(() => {
526525
if (completionFlag && hash) {
526+
const sideEffectResult = this.#getSideEffectFn(fn, args);
527527
while (this.functionCache.size >= maxCacheSize) {
528528
this.functionCache.delete(this.functionCache.keys().next().value);
529529
}
530-
this.functionCache.set(hash, { value: lastValue, time: Date.now() });
530+
this.functionCache.set(hash, {
531+
value: lastValue,
532+
time: Date.now(),
533+
sideEffectFn: sideEffectResult,
534+
});
531535
}
532536
})
533537
)
@@ -714,4 +718,41 @@ export class Execution<
714718
return throwError(new Error(`Unknown AST object: ${JSON.stringify(ast)}`));
715719
}
716720
}
721+
722+
#canUseCachedResult<Fn extends ExpressionFunction>(
723+
fn: Fn,
724+
input: unknown,
725+
args: Record<string, unknown>
726+
):
727+
| { hash: string; value: FunctionCacheItem; valid: boolean }
728+
| { hash: string | undefined; value: undefined; valid: false } {
729+
if (!fn.allowCache || !this.context.allowCache) {
730+
return { hash: undefined, value: undefined, valid: false };
731+
}
732+
const hash = calculateObjectHash([fn.name, input, args, this.context.getSearchContext()]);
733+
734+
const cached = this.functionCache.get(hash);
735+
if (hash && cached) {
736+
return {
737+
hash,
738+
value: cached,
739+
valid: Boolean(cached && Date.now() - cached.time < this.cacheTimeout),
740+
};
741+
}
742+
return {
743+
hash,
744+
value: undefined,
745+
valid: false,
746+
};
747+
}
748+
749+
#getSideEffectFn<Fn extends ExpressionFunction>(
750+
fn: Fn,
751+
args: Record<string, unknown>
752+
): undefined | (() => void) {
753+
if (!fn.allowCache || typeof fn.allowCache === 'boolean') {
754+
return undefined;
755+
}
756+
return fn.allowCache.withSideEffects?.(args, this.context);
757+
}
717758
}

src/platform/plugins/shared/expressions/common/expression_functions/expression_function.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ export class ExpressionFunction implements PersistableState<ExpressionAstFunctio
4242
/**
4343
* Opt-in to caching this function. By default function outputs are cached and given the same inputs cached result is returned.
4444
*/
45-
allowCache: boolean;
45+
allowCache:
46+
| boolean
47+
| { withSideEffects: (params: Record<string, unknown>, handlers: object) => () => void };
4648

4749
/**
4850
* Function to run function (context, args)
@@ -116,7 +118,10 @@ export class ExpressionFunction implements PersistableState<ExpressionAstFunctio
116118
this.fn = fn as ExpressionFunction['fn'];
117119
this.help = help || '';
118120
this.inputTypes = inputTypes || context?.types;
119-
this.allowCache = !!allowCache;
121+
this.allowCache =
122+
allowCache && typeof allowCache !== 'boolean'
123+
? (allowCache as ExpressionFunction['allowCache'])
124+
: Boolean(allowCache);
120125
this.disabled = disabled || false;
121126
this.deprecated = !!deprecated;
122127
this.telemetry = telemetry || ((s, c) => c);

src/platform/plugins/shared/expressions/common/expression_functions/types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,19 @@ export interface ExpressionFunctionDefinition<
6060

6161
/**
6262
* Opt-in to caching this function. By default function outputs are cached and given the same inputs cached result is returned.
63+
*
64+
* It is possible to collect side effects produced by the function
65+
* (e.g. logging, sending events to the server, etc.) and return a
66+
* handler to reproduce such side effects when the function cache is used
67+
* instead of the original function implementation.
68+
* @param args Parameters set for this function in expression.
69+
* @param context Object with functions to perform side effects. This object
70+
* is created for the duration of the execution of expression and is the
71+
* same for all functions in expression chain.
72+
* @returns A handler to be called to reproduce side effects when the function cache is used.
73+
*
6374
*/
64-
allowCache?: boolean;
75+
allowCache?: boolean | { withSideEffects(args: Arguments, context: Context): () => void };
6576

6677
/**
6778
* List of allowed type names for input value of this function. If this

src/platform/plugins/shared/inspector/common/adapters/request/request_adapter.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ import { Request, RequestParams, RequestStatus } from './types';
2121
*/
2222
export class RequestAdapter extends EventEmitter {
2323
private requests: Map<string, Request>;
24+
private responses: WeakMap<Request, RequestResponder>;
2425

2526
constructor() {
2627
super();
2728
this.requests = new Map();
29+
this.responses = new WeakMap();
2830
}
2931

3032
/**
@@ -52,7 +54,17 @@ export class RequestAdapter extends EventEmitter {
5254
};
5355
this.requests.set(req.id, req);
5456
this._onChange();
55-
return new RequestResponder(req, () => this._onChange());
57+
const responder = new RequestResponder(req, () => this._onChange());
58+
this.responses.set(req, responder);
59+
return responder;
60+
}
61+
62+
public loadFromEntries(
63+
requests: Map<string, Request>,
64+
responses: WeakMap<Request, RequestResponder>
65+
) {
66+
this.requests = requests;
67+
this.responses = responses;
5668
}
5769

5870
public reset(): void {
@@ -61,14 +73,24 @@ export class RequestAdapter extends EventEmitter {
6173
}
6274

6375
public resetRequest(id: string): void {
76+
const req = this.requests.get(id);
6477
this.requests.delete(id);
78+
if (req) {
79+
this.responses.delete(req);
80+
}
6581
this._onChange();
6682
}
6783

6884
public getRequests(): Request[] {
6985
return Array.from(this.requests.values());
7086
}
7187

88+
public getRequestEntries(): Array<[Request, RequestResponder]> {
89+
return this.getRequests()
90+
.map((req) => [req, this.responses.get(req)] as [Request, RequestResponder])
91+
.filter(([_req, responder]) => responder != null);
92+
}
93+
7294
private _onChange(): void {
7395
this.emit('change');
7496
}

0 commit comments

Comments
 (0)