Skip to content
This repository was archived by the owner on Oct 16, 2020. It is now read-only.

Commit d870766

Browse files
committed
Add memoize helper with cancellation support
1 parent 148d892 commit d870766

File tree

5 files changed

+239
-47
lines changed

5 files changed

+239
-47
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,11 @@
5959
"@types/mocha": "2.2.32",
6060
"@types/mz": "0.0.30",
6161
"@types/node": "6.0.46",
62+
"@types/sinon": "1.16.35",
6263
"mocha": "^3.2.0",
6364
"nyc": "^10.1.2",
6465
"rimraf": "^2.6.1",
66+
"sinon": "^2.0.0",
6567
"source-map-support": "^0.4.11",
6668
"ts-node": "^1.6.1",
6769
"tslint": "^4.5.1",

src/cancellation.ts

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,113 @@
1-
21
import * as ts from 'typescript';
3-
4-
import { CancellationToken, CancellationTokenSource } from 'vscode-jsonrpc';
2+
import { CancellationToken, CancellationTokenSource, Disposable } from 'vscode-jsonrpc';
53
export { CancellationToken, CancellationTokenSource };
64

5+
/**
6+
* Provides a token that is cancelled as soon as ALL added tokens are cancelled.
7+
* Useful for memoizing a function, where multiple consumers wait for the result of the same operation,
8+
* which should only be cancelled if all consumers requested cancellation.
9+
*/
10+
class CancellationTokenLink {
11+
12+
/**
13+
* Amount of total consumers
14+
*/
15+
private tokens = 0;
16+
17+
/**
18+
* Amount of consumers that have requested cancellation
19+
*/
20+
private cancelled = 0;
21+
22+
/**
23+
* Internal token source that is cancelled when all consumers request cancellation
24+
*/
25+
private source = new CancellationTokenSource();
26+
27+
/**
28+
* A linked CancellationToken that is cancelled as soon as all consumers request cancellation
29+
*/
30+
get token(): CancellationToken {
31+
return this.source.token;
32+
}
33+
34+
/**
35+
* Add another CancellationToken that needs to be cancelled to trigger cancellation
36+
*
37+
* @returns Disposable that allows to remove the token again
38+
*/
39+
add(token: CancellationToken): Disposable {
40+
this.tokens++;
41+
const handlerDisposable = token.onCancellationRequested(() => {
42+
this.cancelled++;
43+
// If all consumers requested cancellation, cancel
44+
if (this.cancelled === this.tokens) {
45+
this.source.cancel();
46+
}
47+
});
48+
return {
49+
dispose: () => {
50+
// Remove onCancellationRequested handler
51+
handlerDisposable.dispose();
52+
this.tokens--;
53+
if (token.isCancellationRequested) {
54+
this.cancelled--;
55+
}
56+
}
57+
};
58+
}
59+
}
60+
61+
/**
62+
* Memoizes the result of a promise-returning function by the first argument with support for cancellation.
63+
* If the last argument is a CancellationToken, the operation is only cancelled if all calls have requested cancellation.
64+
* Rejected (or cancelled) promises are automatically removed from the cache.
65+
* If the operation has already finished, it will not be cancelled.
66+
*
67+
* @param func Function to memoize
68+
* @param cache A custom Map to use for setting and getting cache items
69+
*
70+
* @template F The function to be memoized
71+
* @template T The return type of the function, must be Promise
72+
* @template K The cache key (first argument to the function)
73+
*/
74+
export function cancellableMemoize<F extends (...args: any[]) => T, T extends Promise<any>, K>(func: F, cache = new Map<K, T>()): F {
75+
// Track tokens consumers provide
76+
const tokenLinks = new Map<K, CancellationTokenLink>();
77+
const memoized: F = <any> function (this: any, ...args: any[]) {
78+
const key = args[0];
79+
// Get or create CancellationTokenLink for the given first parameter
80+
let tokenLink = tokenLinks.get(key);
81+
if (!tokenLink) {
82+
tokenLink = new CancellationTokenLink();
83+
tokenLinks.set(key, tokenLink);
84+
}
85+
// Take last argument as CancellationToken from arguments if provided or use a token that is never cancelled
86+
const token: CancellationToken = CancellationToken.is(args[args.length - 1]) ? args.pop() : CancellationToken.None;
87+
// Add it to the list of tokens that need to be cancelled for final cancellation
88+
tokenLink.add(token);
89+
let result: T;
90+
// Check if function has been called with this argument already
91+
if (cache.has(key)) {
92+
// Return previous result
93+
result = cache.get(key);
94+
} else {
95+
// Call function
96+
// Pass the linked cancel token
97+
args.push(tokenLink.token);
98+
result = <T> (<T> func.apply(this, args)).catch(err => {
99+
// Don't cache rejected promises
100+
cache.delete(key);
101+
throw err;
102+
});
103+
// Save result
104+
cache.set(key, result);
105+
}
106+
return result;
107+
};
108+
return memoized;
109+
}
110+
7111
/**
8112
* Thrown when an operation was cancelled
9113
*/

src/fs.ts

Lines changed: 53 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import * as fs from 'mz/fs';
22
import * as path from 'path';
3-
import { CancellationToken, throwIfRequested } from './cancellation';
3+
import { cancellableMemoize, CancellationToken, throwIfRequested } from './cancellation';
44
import { LanguageClientHandler } from './lang-handler';
55
import glob = require('glob');
66
import Semaphore from 'semaphore-async-await';
7+
import * as url from 'url';
78
import { InMemoryFileSystem } from './project-manager';
89
import { path2uri, uri2path } from './util';
910

@@ -74,84 +75,94 @@ export class LocalFileSystem implements FileSystem {
7475
}
7576
}
7677

78+
/**
79+
* Memoization cache that saves URIs and searches parent directories too
80+
*/
81+
class ParentUriMemoizationCache extends Map<string | undefined, Promise<void>> {
82+
83+
/**
84+
* Returns the value if the given URI or a parent directory of the URI is in the cache
85+
*/
86+
get(uri: string | undefined): Promise<void> | undefined {
87+
let hit = super.get(uri);
88+
if (hit) {
89+
return hit;
90+
}
91+
// Find out if parent folder is being fetched already
92+
hit = super.get(undefined);
93+
if (hit) {
94+
return hit;
95+
}
96+
if (uri) {
97+
for (let parts = url.parse(uri); parts.pathname !== '/'; parts.pathname = path.dirname(parts.pathname)) {
98+
hit = super.get(url.format(parts));
99+
if (hit) {
100+
return hit;
101+
}
102+
}
103+
}
104+
return undefined;
105+
}
106+
107+
/**
108+
* Returns true if the given URI or a parent directory of the URI is in the cache
109+
*/
110+
has(key: string): boolean {
111+
return this.get(key) !== undefined;
112+
}
113+
}
114+
77115
/**
78116
* Synchronizes a remote file system to an in-memory file system
79117
*
80118
* TODO: Implement Disposable with Disposer
81119
*/
82120
export class FileSystemUpdater {
83121

84-
/**
85-
* Map from URI to Promise for a content fetch
86-
*/
87-
private fetches = new Map<string, Promise<void>>();
88-
89122
/**
90123
* Limits concurrent fetches to not fetch thousands of files in parallel
91124
*/
92125
private concurrencyLimit = new Semaphore(100);
93126

94-
private structureFetch?: Promise<void>;
95-
96127
constructor(private remoteFs: FileSystem, private inMemoryFs: InMemoryFileSystem) {}
97128

98129
/**
99130
* Fetches the file content for the given URI and adds the content to the in-memory file system
100131
*
101132
* @param uri URI of the file to fetch
102133
*/
103-
async fetch(uri: string, token = CancellationToken.None): Promise<void> {
134+
fetch(uri: string, token = CancellationToken.None): Promise<void> {
104135
// Limit concurrent fetches
105-
const fetch = this.concurrencyLimit.execute(async () => {
136+
return this.concurrencyLimit.execute(async () => {
106137
throwIfRequested(token);
107138
const content = await this.remoteFs.getTextDocumentContent(uri, token);
108-
this.inMemoryFs.addFile(uri2path(uri), content);
109-
});
110-
// Avoid unhandled promise rejection errors (error is still propagated)
111-
fetch.catch(err => {
112-
// Don't cache failed or cancelled fetches
113-
this.fetches.delete(uri);
139+
this.inMemoryFs.add(uri, content);
114140
});
115-
// Track the fetch
116-
this.fetches.set(uri, fetch);
117-
return fetch;
118141
}
119142

120143
/**
121144
* Returns a promise that is resolved when the given URI has been fetched (at least once) to the in-memory file system.
122145
* This function cannot be cancelled because multiple callers get the result of the same operation.
123146
*
124-
* TODO use memoize helper and support cancellation with multiple consumers
125-
*
126147
* @param uri URI of the file to ensure
127148
*/
128-
async ensure(uri: string): Promise<void> {
129-
return this.fetches.get(uri) || this.fetch(uri);
130-
}
149+
ensure = cancellableMemoize(this.fetch);
131150

132151
/**
133-
* Fetches the file/directory structure from the remote file system and saves it in the in-memory file system
152+
* Fetches the file/directory structure for the given directory from the remote file system and saves it in the in-memory file system
153+
*
154+
* @param base The base directory which structure will be synced. Defaults to the workspace root
134155
*/
135-
async fetchStructure(token = CancellationToken.None): Promise<void> {
136-
this.structureFetch = (async () => {
137-
const uris = await this.remoteFs.getWorkspaceFiles(undefined, token);
138-
for (const uri of uris) {
139-
this.inMemoryFs.add(uri);
140-
}
141-
})();
142-
this.structureFetch.catch(err => {
143-
this.structureFetch = undefined;
144-
});
145-
return this.structureFetch;
156+
async fetchStructure(base?: string, token = CancellationToken.None): Promise<void> {
157+
const uris = await this.remoteFs.getWorkspaceFiles(base, token);
158+
for (const uri of uris) {
159+
this.inMemoryFs.add(uri);
160+
}
146161
}
147162

148163
/**
149-
* Returns a promise that is resolved as soon as the file/directory structure has been synced
164+
* Returns a promise that is resolved as soon as the file/directory structure for the given directory has been synced
150165
* from the remote file system to the in-memory file system (at least once)
151-
*
152-
* TODO use memoize helper and support cancellation with multiple consumers
153166
*/
154-
async ensureStructure(): Promise<void> {
155-
return this.structureFetch || this.fetchStructure();
156-
}
167+
ensureStructure = cancellableMemoize(this.fetchStructure, new ParentUriMemoizationCache());
157168
}

src/project-manager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ export class ProjectManager implements Disposable {
292292
.then(() => this.refreshConfigurations());
293293

294294
this.ensuredAllFiles = promise;
295-
promise.catch(err => {
295+
promise.catch((err: any) => {
296296
console.error('Failed to fetch files for references:', err);
297297
this.ensuredAllFiles = undefined;
298298
});
@@ -468,7 +468,7 @@ export class ProjectManager implements Disposable {
468468
await Promise.all(iterate(files).map(async path => {
469469
throwIfRequested(token);
470470
try {
471-
await this.updater.ensure(util.path2uri('', path));
471+
await this.updater.ensure(util.path2uri('', path), token);
472472
} catch (err) {
473473
// if cancellation was requested, break out of the loop
474474
throwIfCancelledError(err);

src/test/cancellation.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
2+
import * as chai from 'chai';
3+
import { cancellableMemoize, CancellationToken, CancellationTokenSource, CancelledError } from '../cancellation';
4+
import chaiAsPromised = require('chai-as-promised');
5+
import * as sinon from 'sinon';
6+
chai.use(chaiAsPromised);
7+
const assert = chai.assert;
8+
9+
describe('cancellation', () => {
10+
describe('cancellableMemoize()', () => {
11+
it('should memoize a function by the first argument', async () => {
12+
const toBeMemoized = sinon.spy((arg1: number, arg2: number, token = CancellationToken.None): Promise<number> => {
13+
return Promise.resolve(Math.random());
14+
});
15+
const memoized = cancellableMemoize(toBeMemoized);
16+
const a = await memoized(123, 456);
17+
const b = await memoized(123, 456);
18+
sinon.assert.calledOnce(toBeMemoized);
19+
sinon.assert.calledWith(toBeMemoized, 123, 456, sinon.match.object);
20+
assert.equal(a, b);
21+
});
22+
it('should memoize a function without parameters', async () => {
23+
const toBeMemoized = sinon.spy((token = CancellationToken.None): Promise<number> => {
24+
return Promise.resolve(Math.random());
25+
});
26+
const memoized = cancellableMemoize(toBeMemoized);
27+
const a = await memoized();
28+
const b = await memoized();
29+
assert.equal(a, b);
30+
sinon.assert.calledOnce(toBeMemoized);
31+
sinon.assert.calledWith(toBeMemoized, sinon.match.object);
32+
});
33+
it('should not cancel the operation if there are still consumers', async () => {
34+
const toBeMemoized = sinon.spy((arg: number, token = CancellationToken.None): Promise<number> => {
35+
return new Promise((resolve, reject) => {
36+
token.onCancellationRequested(() => {
37+
reject(new CancelledError());
38+
});
39+
setTimeout(() => resolve(123), 500);
40+
});
41+
});
42+
const memoized = cancellableMemoize(toBeMemoized);
43+
const source1 = new CancellationTokenSource();
44+
const source2 = new CancellationTokenSource();
45+
const promise1 = memoized(123, source1.token);
46+
const promise2 = memoized(123, source2.token);
47+
source1.cancel();
48+
assert.equal(await promise1, 123);
49+
assert.equal(await promise2, 123);
50+
sinon.assert.calledOnce(toBeMemoized);
51+
sinon.assert.calledWith(toBeMemoized, 123, sinon.match.object);
52+
});
53+
it('should cancel the operation if all consumers requested cancellation', async () => {
54+
const toBeMemoized = sinon.spy((arg: number, token: CancellationToken): Promise<number> => {
55+
return new Promise((resolve, reject) => {
56+
token.onCancellationRequested(() => {
57+
reject(new CancelledError());
58+
});
59+
setTimeout(() => resolve(123), 500);
60+
});
61+
});
62+
const memoized = cancellableMemoize(toBeMemoized);
63+
const source1 = new CancellationTokenSource();
64+
const source2 = new CancellationTokenSource();
65+
const promise1 = memoized(123, source1.token);
66+
const promise2 = memoized(123, source2.token);
67+
source1.cancel();
68+
source2.cancel();
69+
sinon.assert.calledOnce(toBeMemoized);
70+
sinon.assert.calledWith(toBeMemoized, 123, sinon.match.object);
71+
await assert.isRejected(promise1, /cancel/i);
72+
await assert.isRejected(promise2, /cancel/i);
73+
});
74+
});
75+
});

0 commit comments

Comments
 (0)