Skip to content

Commit a5f0f3f

Browse files
authored
Optimized resource retrieval for FetchEnvs (#383)
* optimized resource retrieval for FetchEnvs * linting and testing fixes, package updates * enable global cache of retrieved resources with ttl * comment clarification * typofix * tweak logging
1 parent fd75a97 commit a5f0f3f

File tree

4 files changed

+231
-52
lines changed

4 files changed

+231
-52
lines changed

lib/FetchEnvs.js

Lines changed: 201 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ const KIND_MAP = new Map([
2727
['configMapKeyRef', 'ConfigMap']
2828
]);
2929

30+
const LRU = require('lru-cache');
31+
const LruOptions = {
32+
maxSize: 100000, // the max cache size
33+
sizeCalculation: (r) => { return( JSON.stringify(r).length ); }, // how to determine the size of a resource added to the cache
34+
ttl: 1000 * 60 * 3, // max time to cache (LRU does not directly enforce, but maxSize will eventually push them out)
35+
updateAgeOnGet: false, // Don't update ttl when an item is retrieved from cache
36+
updateAgeOnHas: false, // Don't update ttl when an item is checked in cache
37+
};
38+
const globalResourceCache = new LRU( LruOptions );
39+
const globalResourceCacheUsers = new Set();
40+
3041
module.exports = class FetchEnvs {
3142

3243
get [Symbol.toStringTag]() {
@@ -45,6 +56,71 @@ module.exports = class FetchEnvs {
4556
this.updateRazeeLogs = controllerObject.updateRazeeLogs ?
4657
((logLevel, log) => { controllerObject.updateRazeeLogs(logLevel, log); }) :
4758
(() => { log.debug('\'updateRazeeLogs()\' not passed to fetchEnvs. will not update razeeLogs on failure to fetch envs'); });
59+
60+
const user = this.data?.object?.spec?.clusterAuth?.impersonateUser;
61+
if( process.env.INSTANCE_FETCHENVS_CACHE_ONLY ) {
62+
// Using `user` is not technically necessary for an instance-specific cache, but used for consistency
63+
log.info( 'FetchEnvs.constructor using instance-specific resource cache' );
64+
this.instanceCache = {};
65+
this.resourceCache = {
66+
has: (key) => {
67+
const hit = Object.prototype.hasOwnProperty.call(this.instanceCache, `${user}/${key}`);
68+
log.info( `FetchEnvs cache ${hit?'HIT':'MISS'}: '${user}/${key}'` );
69+
return hit;
70+
},
71+
set: (key, value) => { this.instanceCache[`${user}/${key}`] = value; },
72+
get: (key) => { return this.instanceCache[`${user}/${key}`]; },
73+
};
74+
}
75+
else {
76+
log.info( `FetchEnvs.constructor using global resource cache, ${globalResourceCache.size} resources currently cached (may be TTL expired)` );
77+
this.resourceCache = {
78+
has: (key) => {
79+
const hit = globalResourceCache.has(`${user}/${key}`);
80+
log.info( `FetchEnvs cache ${hit?'HIT':'MISS'}: '${user}/${key}'` );
81+
return hit;
82+
},
83+
set: (key, value) => {
84+
// When setting a key, keep track of users to allow later deletion
85+
globalResourceCacheUsers.add( user );
86+
globalResourceCache.set(`${user}/${key}`, value);
87+
log.info( `FetchEnvs cached '${user}/${key}'` );
88+
},
89+
get: (key) => {
90+
return globalResourceCache.get(`${user}/${key}`);
91+
},
92+
};
93+
}
94+
}
95+
96+
// This function needs to be called any time a watch on a potentially cached item is triggered by creation/update/poll, e.g. in the ReferencedResourceManager
97+
// If it is not, the old resource may still be served from cache until the TTL expires
98+
static updateInGlobalCache(resource) {
99+
const cacheKey = [resource?.apiVersion, resource?.kind, resource?.metadata?.namespace, resource?.metadata?.name].join('/');
100+
let updated = false;
101+
// When updating a key, updating it for all users
102+
for( const cacheUser of globalResourceCacheUsers ) {
103+
if( globalResourceCache.has(`${cacheUser}/${cacheKey}`) ) {
104+
globalResourceCache.set(`${cacheUser}/${cacheKey}`, resource);
105+
updated = true;
106+
}
107+
}
108+
if( updated ) log.info( `FetchEnvs cache updated for "*/${cacheKey}"` );
109+
}
110+
111+
// This function needs to be called any time a watch on a potentially cached item is triggered by deletion, e.g. in the ReferencedResourceManager
112+
// If it is not, the deleted resource may still be served from cache until the TTL expires
113+
static deleteFromGlobalCache(resource) {
114+
const cacheKey = [resource?.apiVersion, resource?.kind, resource?.metadata?.namespace, resource?.metadata?.name].join('/');
115+
let deleted = false;
116+
// When deleting a key, delete it for all users
117+
for( const cacheUser of globalResourceCacheUsers ) {
118+
if( globalResourceCache.has(`${cacheUser}/${cacheKey}`) ) {
119+
globalResourceCache.delete(`${cacheUser}/${cacheKey}`);
120+
deleted = true;
121+
}
122+
}
123+
if( deleted ) log.info( `FetchEnvs cache deleted for "*/${cacheKey}"` );
48124
}
49125

50126
#secretMapRef(conf) {
@@ -63,6 +139,13 @@ module.exports = class FetchEnvs {
63139
return this.#genericKeyRef(conf, 'configMapKeyRef');
64140
}
65141

142+
/*
143+
@param[I] conf An object like `{ configMapRef: { name: 'asdf', namespace: 'asdf' } }`.
144+
@param[I] valueFrom The name of the conf attribute containing resource details, e.g. `configMapRef`.
145+
@param[I] decode A boolean indicating whether to base64 decode the values retrieved, e.g. from Secrets
146+
147+
@return An object like { configMapRef: { name: 'asdf', namespace: 'asdf' }, data: { key1: val1, ... } }
148+
*/
66149
async #genericMapRef(conf, valueFrom = 'genericMapRef', decode = false) {
67150
let resource;
68151
let kubeError = ERR_NODATA;
@@ -76,13 +159,22 @@ module.exports = class FetchEnvs {
76159
name
77160
} = ref;
78161

79-
const krm = await this.kubeClass.getKubeResourceMeta(apiVersion, kind, 'update');
162+
const cacheKey = [apiVersion, kind, namespace, name].join('/');
163+
if( this.resourceCache.has( cacheKey ) ) {
164+
resource = this.resourceCache.get( cacheKey );
165+
}
166+
else {
167+
const krm = await this.kubeClass.getKubeResourceMeta(apiVersion, kind, 'update');
80168

81-
if (krm) {
82-
try {
83-
resource = await krm.get(name, namespace);
84-
} catch (error) {
85-
kubeError = error;
169+
if (krm) {
170+
try {
171+
resource = await krm.get(name, namespace);
172+
if( resource ) {
173+
this.resourceCache.set( cacheKey, resource ); // Cache this resource
174+
}
175+
} catch (error) {
176+
kubeError = error;
177+
}
86178
}
87179
}
88180

@@ -108,6 +200,14 @@ module.exports = class FetchEnvs {
108200
return { ...conf, data };
109201
}
110202

203+
/*
204+
@param[I] conf An object like `{ default: '{default:true}', overrideStrategy: 'merge', configMapRef: { name: 'asdf', namespace: 'asdf', key: 'asdf', type: 'json' } }`
205+
- name, namespace, and matchLabels identify the resource
206+
- key identifies the data inside the resource
207+
- type identifies how to typecast the value
208+
209+
@return The discovered value
210+
*/
111211
async #genericKeyRef(conf, valueFrom = 'genericKeyRef', decode = false) {
112212
let response;
113213
let kubeError = ERR_NODATA;
@@ -125,36 +225,52 @@ module.exports = class FetchEnvs {
125225
apiVersion = 'v1'
126226
} = ref;
127227

128-
const krm = await this.kubeClass.getKubeResourceMeta(
129-
apiVersion,
130-
kind,
131-
'update'
132-
);
133-
134228
const matchLabelsQS = labelSelectors(matchLabels);
135229

136-
if (krm) {
137-
try {
138-
response = await this.api({
139-
uri: krm.uri({ namespace, name }),
140-
json: true,
141-
qs: matchLabelsQS
142-
});
143-
} catch (error) {
144-
kubeError = error;
230+
const cacheKey = [apiVersion, kind, namespace, name].join('/');
231+
// Note: Using `matchLabels` will always result in a kube api call, label-based queries cannot use the resourceCache
232+
if( !matchLabelsQS && this.resourceCache.has(cacheKey) ) {
233+
response = this.resourceCache.get(cacheKey);
234+
}
235+
else {
236+
const krm = await this.kubeClass.getKubeResourceMeta(apiVersion, kind, 'update');
237+
238+
if (krm) {
239+
try {
240+
response = await this.api({
241+
uri: krm.uri({ namespace, name }),
242+
json: true,
243+
qs: matchLabelsQS
244+
});
245+
// Note: cache here only if getting a single resource
246+
if( response?.data && !response?.items ) {
247+
this.resourceCache.set(cacheKey, response);
248+
}
249+
} catch (error) {
250+
kubeError = error;
251+
}
145252
}
146253
}
147254

148255
let value = response?.data?.[key];
149256

257+
// If matching by labels, there can be multiple matching resources.
258+
// Reduce to a single value via the specified strategy ('merge' combines objects, otherwise a single value is picked).
150259
if (typeof matchLabelsQS === OBJECT) {
260+
// Cache here if there are multiple retrieved resources
261+
if( response?.items ) {
262+
response.items.forEach(function (item) {
263+
const cacheKey = [item.apiVersion, item.kind, item.metadata.namespace, item.metadata.name].join('/');
264+
this.resourceCache.set(cacheKey, item);
265+
}, this);
266+
}
151267
const output = response?.items.reduce(
152268
reduceItemList(ref, strategy, decode),
153269
Object.create(null)
154270
);
155271

156272
value = output?.[key];
157-
decode = false;
273+
decode = false; // 'decode' was used in the reduceItemList, set to false to avoid double-decoding.
158274
}
159275

160276
if (value === undefined) {
@@ -182,9 +298,15 @@ module.exports = class FetchEnvs {
182298
return typeCast(name, value, type);
183299
}
184300

301+
/*
302+
Retrieve all values from specified kube resources.
303+
304+
@param[I] envs Array of objects like `[ { configMapRef: { ... }, ... } ]`
185305
186-
processEnvFrom(envFrom) {
187-
return Promise.all(envFrom.map((element) => {
306+
@return Array of objects like ``[ { configMapRef: { ... }, data: { key1: val1, key2: val2, ... } }, ... ]``
307+
*/
308+
async processEnvFrom(envFrom) {
309+
const retVal = await Promise.all(envFrom.map((element) => {
188310
const { configMapRef, secretMapRef, genericMapRef } = element;
189311

190312
if (!configMapRef && !secretMapRef && !genericMapRef) {
@@ -195,25 +317,44 @@ module.exports = class FetchEnvs {
195317
if (secretMapRef) return this.#secretMapRef(element);
196318
return this.#genericMapRef(element);
197319
}));
320+
return( retVal );
198321
}
199322

200-
#processEnv(envs) {
201-
return Promise.all(envs.map(async (env) => {
202-
if (env.value) return env;
203-
const valueFrom = env.valueFrom || {};
204-
const { genericKeyRef, configMapKeyRef, secretKeyRef } = valueFrom;
323+
/*
324+
Retrieve specific values from specified kube resources.
325+
326+
Each env is retrieved and processed sequentially so that caching can take place.
327+
If Promise.all were used, multiple requests for the same resource would be sent
328+
in parallel and caching would be unable to assist. The return value is an array
329+
as if from Promise.all.
205330
206-
if (!genericKeyRef && !configMapKeyRef && !secretKeyRef) {
207-
throw new Error(`oneOf genericKeyRef, configMapKeyRef, secretKeyRef must be defined. Got: ${JSON.stringify(env)}`);
331+
@param[I] envs Array of objects like `[ { configMapKeyRef: { ... }, ... } ]`
332+
333+
@return Array of objects like `[ { configMapKeyRef: { ... }, value: asdf }, ... ]`
334+
*/
335+
async #processEnv(envs) {
336+
const retVal = [];
337+
for( const env of envs ) {
338+
if (env.value) {
339+
retVal.push( env );
208340
}
341+
else {
342+
const valueFrom = env.valueFrom || {};
343+
const { genericKeyRef, configMapKeyRef, secretKeyRef } = valueFrom;
209344

210-
let value;
211-
if (secretKeyRef) value = await this.#secretKeyRef(env);
212-
if (configMapKeyRef) value = await this.#configMapKeyRef(env);
213-
if (genericKeyRef) value = await this.#genericKeyRef(env);
345+
if (!genericKeyRef && !configMapKeyRef && !secretKeyRef) {
346+
throw new Error(`oneOf genericKeyRef, configMapKeyRef, secretKeyRef must be defined. Got: ${JSON.stringify(env)}`);
347+
}
214348

215-
return { ...env, value };
216-
}));
349+
let value;
350+
if (secretKeyRef) value = await this.#secretKeyRef(env);
351+
if (configMapKeyRef) value = await this.#configMapKeyRef(env);
352+
if (genericKeyRef) value = await this.#genericKeyRef(env);
353+
354+
retVal.push( { ...env, value } );
355+
}
356+
}
357+
return retVal;
217358
}
218359

219360
#processEnvSourceSimpleLinks(envs) {
@@ -225,21 +366,31 @@ module.exports = class FetchEnvs {
225366
}));
226367
}
227368

369+
/*
370+
Retrieve values specified in spec.envFrom and spec.env elements
371+
372+
@param[I] path path to the env and envFrom elements in the resource
373+
374+
@return A map of keys to values
375+
*/
228376
async get(path = 'spec') {
229377
let result = {};
230378
// removes any number of '.' at the start and end of the path, and
231379
// removes the '.env' or '.envFrom' if the paths ends in either
232380
path = path.replace(/^\.*|\.*$|(\.envFrom\.*$)|(\.env\.*$)/g, '');
233381

234382
let envFrom = objectPath.get(this.data, `object.${path}.envFrom`, []);
383+
235384
envFrom = await this.processEnvFrom(envFrom);
236385
for (const env of envFrom) {
237386
const data = env?.data ?? {};
238387
result = { ...result, ...data };
239388
}
240389

241-
const env = objectPath.get(this.data, `object.${path}.env`, []);
242-
return (await this.#processEnv(env)).reduce(reduceEnv, result);
390+
let env = objectPath.get(this.data, `object.${path}.env`, []);
391+
392+
env = await this.#processEnv(env);
393+
return (env).reduce(reduceEnv, result);
243394
}
244395

245396
async getSourceSimpleLinks(path = 'spec') {
@@ -319,6 +470,17 @@ function labelSelectors(query) {
319470
};
320471
}
321472

473+
/*
474+
Cast the specified value to the indicated type. The value is returned unmodified if:
475+
- 'type' is not specified
476+
- 'value' is null or not a string
477+
478+
@param[I] name The name of the reference from which the value was obtained. Used only in generating error text in case of JSON parsing errors.
479+
@param[I] value The string value to typecast.
480+
@param[I] type How to typecast the value.
481+
482+
@return Value, cast to the indicated type (e.g. number, boolean, json, base64 decoded string )
483+
*/
322484
function typeCast(name, value, type) {
323485
if (!type) return value;
324486
if (value == null) return;

lib/ReferencedResourceManager.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ module.exports = class ReferencedResourceManager {
8181

8282
this._logger.info(`${this._data.type} event received ${this._selfLink} ${objectPath.get(this._data, 'object.metadata.resourceVersion')}`);
8383

84+
// Update or remove the resource from the FetchEnvs cache so that later FetchEnvs instances will use a fresh copy
85+
if( [ 'ADDED', 'POLLED', 'MODIFIED' ].includes( this._data.type ) ) {
86+
FetchEnvs.updateInGlobalCache( objectPath.get(this._data, 'object') );
87+
}
88+
else {
89+
FetchEnvs.deleteFromGlobalCache( objectPath.get(this._data, 'object') );
90+
}
91+
8492
let clusterLocked = await this._cluster_locked();
8593
if (clusterLocked) {
8694
this._logger.info(`Cluster lock has been set.. skipping ${this._data.type} event ${this._selfLink} ${objectPath.get(this._data, 'object.metadata.resourceVersion')}`);

0 commit comments

Comments
 (0)