Skip to content

Commit d59f93e

Browse files
ericsnowcurrentlyluabud
authored andcommitted
Add CachingLocator. (microsoft#14020)
Note that we factored out the BackgroundRequestLooper class to keep CachingLocator focused, especially as it was important to deal with one "refresh" at a time.
1 parent af2027b commit d59f93e

File tree

8 files changed

+812
-59
lines changed

8 files changed

+812
-59
lines changed
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { createDeferred } from './async';
5+
6+
type RequestID = number;
7+
type RunFunc = () => Promise<void>;
8+
type NotifyFunc = () => void;
9+
10+
/**
11+
* This helps avoid running duplicate expensive operations.
12+
*
13+
* The key aspect is that already running or queue requests can be
14+
* re-used instead of creating a duplicate request.
15+
*/
16+
export class BackgroundRequestLooper {
17+
private readonly opts: {
18+
runDefault: RunFunc;
19+
};
20+
21+
private started = false;
22+
23+
private stopped = false;
24+
25+
private readonly done = createDeferred<void>();
26+
27+
private readonly loopRunning = createDeferred<void>();
28+
29+
private waitUntilReady = createDeferred<void>();
30+
31+
private running: RequestID | undefined;
32+
33+
// For now we don't worry about a max queue size.
34+
private readonly queue: RequestID[] = [];
35+
36+
private readonly requests: Record<RequestID, [RunFunc, Promise<void>, NotifyFunc]> = {};
37+
38+
private lastID: number | undefined;
39+
40+
constructor(
41+
opts: {
42+
runDefault?: RunFunc | null;
43+
} = {}
44+
) {
45+
this.opts = {
46+
runDefault:
47+
opts.runDefault ??
48+
(async () => {
49+
throw Error('no default operation provided');
50+
})
51+
};
52+
}
53+
54+
/**
55+
* Start the request execution loop.
56+
*
57+
* Currently it does not support being re-started.
58+
*/
59+
public start(): void {
60+
if (this.stopped) {
61+
throw Error('already stopped');
62+
}
63+
if (this.started) {
64+
return;
65+
}
66+
this.started = true;
67+
68+
this.runLoop().ignoreErrors();
69+
}
70+
71+
/**
72+
* Stop the loop (assuming it was already started.)
73+
*
74+
* @returns - a promise that resolves once the loop has stopped.
75+
*/
76+
public stop(): Promise<void> {
77+
if (this.stopped) {
78+
return this.loopRunning.promise;
79+
}
80+
if (!this.started) {
81+
throw Error('not started yet');
82+
}
83+
this.stopped = true;
84+
85+
this.done.resolve();
86+
87+
// It is conceivable that a separate "waitUntilStopped"
88+
// operation would be useful. If it turned out to be desirable
89+
// then at the point we could add such a method separately.
90+
// It would do nothing more than `await this.loopRunning`.
91+
// Currently there is no need for a separate method since
92+
// returning the promise here is sufficient.
93+
return this.loopRunning.promise;
94+
}
95+
96+
/**
97+
* Return the most recent active request, if any.
98+
*
99+
* If there are no pending requests then this is the currently
100+
* running one (if one is running).
101+
*
102+
* @returns - the ID of the request and its completion promise;
103+
* if there are no active requests then you get `undefined`
104+
*/
105+
public getLastRequest(): [RequestID, Promise<void>] | undefined {
106+
let reqID: RequestID;
107+
if (this.queue.length > 0) {
108+
reqID = this.queue[this.queue.length - 1];
109+
} else if (this.running !== undefined) {
110+
reqID = this.running;
111+
} else {
112+
return undefined;
113+
}
114+
// The req cannot be undefined since every queued ID has a request.
115+
const [, promise] = this.requests[reqID];
116+
if (reqID === undefined) {
117+
// The queue must be empty.
118+
return undefined;
119+
}
120+
return [reqID, promise];
121+
}
122+
123+
/**
124+
* Return the request that is waiting to run next, if any.
125+
*
126+
* The request is the next one that will be run. This implies that
127+
* there is one already running.
128+
*
129+
* @returns - the ID of the request and its completion promise;
130+
* if there are no pending requests then you get `undefined`
131+
*/
132+
public getNextRequest(): [RequestID, Promise<void>] | undefined {
133+
if (this.queue.length === 0) {
134+
return undefined;
135+
}
136+
const reqID = this.queue[0];
137+
// The req cannot be undefined since every queued ID has a request.
138+
const [, promise] = this.requests[reqID]!;
139+
return [reqID, promise];
140+
}
141+
142+
/**
143+
* Request that a function be run.
144+
*
145+
* If one is already running then the new request is added to the
146+
* end of the queue. Otherwise it is run immediately.
147+
*
148+
* @returns - the ID of the new request and its completion promise;
149+
* the promise resolves once the request has completed
150+
*/
151+
public addRequest(run?: RunFunc): [RequestID, Promise<void>] {
152+
const reqID = this.getNextID();
153+
// This is the only method that adds requests to the queue
154+
// and `getNextID()` keeps us from having collisions here.
155+
// So we are guaranteed that there are no matching requests
156+
// in the queue.
157+
const running = createDeferred<void>();
158+
this.requests[reqID] = [
159+
// [RunFunc, "done" promise, NotifyFunc]
160+
run ?? this.opts.runDefault,
161+
running.promise,
162+
() => running.resolve()
163+
];
164+
this.queue.push(reqID);
165+
if (this.queue.length === 1) {
166+
// `waitUntilReady` will get replaced with a new deferred
167+
// in the loop once the existing one gets used.
168+
// We let the queue clear out before triggering the loop
169+
// again.
170+
this.waitUntilReady.resolve();
171+
}
172+
return [reqID, running.promise];
173+
}
174+
175+
/**
176+
* This is the actual loop where the queue is managed and waiting happens.
177+
*/
178+
private async runLoop(): Promise<void> {
179+
const getWinner = () => {
180+
const promises = [
181+
// These are the competing operations.
182+
// Note that the losers keep running in the background.
183+
this.done.promise.then(() => 0),
184+
this.waitUntilReady.promise.then(() => 1)
185+
];
186+
return Promise.race(promises);
187+
};
188+
189+
let winner = await getWinner();
190+
while (!this.done.completed) {
191+
if (winner === 1) {
192+
this.waitUntilReady = createDeferred<void>();
193+
await this.flush();
194+
} else {
195+
// This should not be reachable.
196+
throw Error(`unsupported winner ${winner}`);
197+
}
198+
winner = await getWinner();
199+
}
200+
this.loopRunning.resolve();
201+
}
202+
203+
/**
204+
* Run all pending requests, in queue order.
205+
*
206+
* Each request's completion promise resolves once that request
207+
* finishes.
208+
*/
209+
private async flush(): Promise<void> {
210+
if (this.running !== undefined) {
211+
// We must be flushing the queue already.
212+
return;
213+
}
214+
// Run every request in the queue.
215+
while (this.queue.length > 0) {
216+
const reqID = this.queue[0];
217+
this.running = reqID;
218+
// We pop the request off the queue here so it doesn't show
219+
// up as both running and pending.
220+
this.queue.shift();
221+
const [run, , notify] = this.requests[reqID];
222+
223+
await run();
224+
225+
// We leave the request until right before `notify()`
226+
// for the sake of any calls to `getLastRequest()`.
227+
delete this.requests[reqID];
228+
notify();
229+
}
230+
this.running = undefined;
231+
}
232+
233+
/**
234+
* Provide the request ID to use next.
235+
*/
236+
private getNextID(): RequestID {
237+
// For now there is no way to queue up a request with
238+
// an ID that did not originate here. So we don't need
239+
// to worry about collisions.
240+
if (this.lastID === undefined) {
241+
this.lastID = 1;
242+
} else {
243+
this.lastID += 1;
244+
}
245+
return this.lastID;
246+
}
247+
}

src/client/pythonEnvironments/base/envsCache.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// Licensed under the MIT License.
33

44
import { cloneDeep } from 'lodash';
5-
import { getGlobalPersistentStore, IPersistentStore } from '../common/externalDependencies';
65
import { PythonEnvInfo } from './info';
76
import { areSameEnv } from './info/env';
87

@@ -13,7 +12,7 @@ export interface IEnvsCache {
1312
/**
1413
* Initialization logic to be done outside of the constructor, for example reading from persistent storage.
1514
*/
16-
initialize(): void;
15+
initialize(): Promise<void>;
1716

1817
/**
1918
* Return all environment info currently in memory for this session.
@@ -30,7 +29,7 @@ export interface IEnvsCache {
3029
setAllEnvs(envs: PythonEnvInfo[]): void;
3130

3231
/**
33-
* If the cache has been initialized, return environmnent info objects that match a query object.
32+
* If the cache has been initialized, return environment info objects that match a query object.
3433
* If none of the environments in the cache match the query data, return an empty array.
3534
* If the in-memory cache has not been initialized prior to calling `filterEnvs`, return `undefined`.
3635
*
@@ -40,14 +39,19 @@ export interface IEnvsCache {
4039
* @return The environment info objects matching the `env` param,
4140
* or `undefined` if the in-memory cache is not initialized.
4241
*/
43-
filterEnvs(env: PythonEnvInfo | string): PythonEnvInfo[] | undefined;
42+
filterEnvs(query: Partial<PythonEnvInfo>): PythonEnvInfo[] | undefined;
4443

4544
/**
4645
* Writes the content of the in-memory cache to persistent storage.
4746
*/
4847
flush(): Promise<void>;
4948
}
5049

50+
export interface IPersistentStorage {
51+
load(): Promise<PythonEnvInfo[] | undefined>;
52+
store(envs: PythonEnvInfo[]): Promise<void>;
53+
}
54+
5155
type CompleteEnvInfoFunction = (envInfo: PythonEnvInfo) => boolean;
5256

5357
/**
@@ -58,18 +62,23 @@ export class PythonEnvInfoCache implements IEnvsCache {
5862

5963
private envsList: PythonEnvInfo[] | undefined;
6064

61-
private persistentStorage: IPersistentStore<PythonEnvInfo[]> | undefined;
65+
private persistentStorage: IPersistentStorage | undefined;
6266

63-
constructor(private readonly isComplete: CompleteEnvInfoFunction) {}
67+
constructor(
68+
private readonly isComplete: CompleteEnvInfoFunction,
69+
private readonly getPersistentStorage?: () => IPersistentStorage,
70+
) {}
6471

65-
public initialize(): void {
72+
public async initialize(): Promise<void> {
6673
if (this.initialized) {
6774
return;
6875
}
6976

7077
this.initialized = true;
71-
this.persistentStorage = getGlobalPersistentStore<PythonEnvInfo[]>('PYTHON_ENV_INFO_CACHE');
72-
this.envsList = this.persistentStorage?.get();
78+
if (this.getPersistentStorage !== undefined) {
79+
this.persistentStorage = this.getPersistentStorage();
80+
this.envsList = await this.persistentStorage.load();
81+
}
7382
}
7483

7584
public getAllEnvs(): PythonEnvInfo[] | undefined {
@@ -80,21 +89,19 @@ export class PythonEnvInfoCache implements IEnvsCache {
8089
this.envsList = cloneDeep(envs);
8190
}
8291

83-
public filterEnvs(env: PythonEnvInfo | string): PythonEnvInfo[] | undefined {
84-
const result = this.envsList?.filter((info) => areSameEnv(info, env));
85-
86-
if (result) {
87-
return cloneDeep(result);
92+
public filterEnvs(query: Partial<PythonEnvInfo>): PythonEnvInfo[] | undefined {
93+
if (this.envsList === undefined) {
94+
return undefined;
8895
}
89-
90-
return undefined;
96+
const result = this.envsList.filter((info) => areSameEnv(info, query));
97+
return cloneDeep(result);
9198
}
9299

93100
public async flush(): Promise<void> {
94101
const completeEnvs = this.envsList?.filter(this.isComplete);
95102

96103
if (completeEnvs?.length) {
97-
await this.persistentStorage?.set(completeEnvs);
104+
await this.persistentStorage?.store(completeEnvs);
98105
}
99106
}
100107
}

0 commit comments

Comments
 (0)