Skip to content

Commit 93d4431

Browse files
committed
Use a cache to avoid excessive reloading of tsdoc.json
1 parent 324795f commit 93d4431

File tree

2 files changed

+154
-11
lines changed

2 files changed

+154
-11
lines changed

eslint-plugin/src/index.ts

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import {
1010
} from '@microsoft/tsdoc-config';
1111
import * as eslint from "eslint";
1212
import * as ESTree from "estree";
13+
import * as path from 'path';
1314

1415
const tsdocMessageIds: {[x: string]: string} = {};
1516

16-
const defaultTSDocConfiguration: TSDocConfiguration = new TSDocConfiguration()
17+
const defaultTSDocConfiguration: TSDocConfiguration = new TSDocConfiguration();
1718
defaultTSDocConfiguration.allTsdocMessageIds.forEach((messageId: string) => {
1819
tsdocMessageIds[messageId] = `${messageId}: {{unformattedText}}`;
1920
});
@@ -22,6 +23,33 @@ interface IPlugin {
2223
rules: {[x: string]: eslint.Rule.RuleModule};
2324
}
2425

26+
// To debug the plugin, temporarily uncomment the body of this function
27+
function debug(message: string): void {
28+
message = require("process").pid + ": " + message;
29+
console.log(message);
30+
require('fs').writeFileSync('C:\\Git\\log.txt', message + '\r\n', { flag: 'as' });
31+
}
32+
33+
interface ICachedConfig {
34+
loadTimeMs: number;
35+
lastCheckTimeMs: number;
36+
configFile: TSDocConfigFile;
37+
}
38+
39+
// How often to check for modified input files. If a file's modification timestamp has changed, then we will
40+
// evict the cache entry immediately.
41+
const CACHE_CHECK_INTERVAL_MS: number = 15*1000;
42+
43+
// Evict old entries from the cache after this much time, regardless of whether the file was detected as being
44+
// modified or not.
45+
const CACHE_EXPIRE_MS: number = 30*1000;
46+
47+
// If this many objects accumulate in the cache, then it is cleared to avoid a memory leak.
48+
const CACHE_MAX_SIZE: number = 100;
49+
50+
// findConfigPathForFolder() result --> loaded tsdoc.json configuration
51+
const cachedConfigs: Map<string, ICachedConfig> = new Map<string, ICachedConfig>();
52+
2553
const plugin: IPlugin = {
2654
rules: {
2755
// NOTE: The actual ESLint rule name will be "tsdoc/syntax". It is calculated by deleting "eslint-plugin-"
@@ -43,10 +71,67 @@ const plugin: IPlugin = {
4371
}
4472
},
4573
create: (context: eslint.Rule.RuleContext) => {
46-
const tsdocConfiguration: TSDocConfiguration = new TSDocConfiguration();
47-
4874
const sourceFilePath: string = context.getFilename();
49-
const tsdocConfigFile: TSDocConfigFile = TSDocConfigFile.loadForFolder(sourceFilePath);
75+
76+
debug(`Linting: "${sourceFilePath}"`);
77+
78+
// First, determine the file to be loaded. If not found, the configFilePath will be an empty string.
79+
const configFilePath: string ='';// TSDocConfigFile.findConfigPathForFolder(sourceFilePath);
80+
81+
// If configFilePath is an empty string, then we'll use the folder of sourceFilePath as our cache key
82+
// (instead of an empty string)
83+
const cacheKey: string = configFilePath || path.dirname(sourceFilePath);
84+
debug(`Cache key: "${cacheKey}"`);
85+
86+
const nowMs: number = (new Date()).getTime();
87+
88+
let cachedConfig: ICachedConfig | undefined = undefined;
89+
90+
// Do we have a cached object?
91+
cachedConfig = cachedConfigs.get(cacheKey);
92+
93+
if (cachedConfig) {
94+
debug('Cache hit');
95+
96+
// Is the cached object still valid?
97+
const loadAgeMs: number = nowMs - cachedConfig.loadTimeMs;
98+
const lastCheckAgeMs: number = nowMs - cachedConfig.lastCheckTimeMs;
99+
100+
if (loadAgeMs > CACHE_EXPIRE_MS || loadAgeMs < 0) {
101+
debug('Evicting because item is expired');
102+
cachedConfig = undefined;
103+
cachedConfigs.delete(cacheKey);
104+
} else if (lastCheckAgeMs > CACHE_CHECK_INTERVAL_MS || lastCheckAgeMs < 0) {
105+
debug('Checking for modifications');
106+
cachedConfig.lastCheckTimeMs = nowMs;
107+
if (cachedConfig.configFile.checkForModifiedFiles()) {
108+
// Invalidate the cache because it failed to load completely
109+
debug('Evicting because item was modified');
110+
cachedConfig = undefined;
111+
cachedConfigs.delete(cacheKey);
112+
}
113+
}
114+
}
115+
116+
// Load the object
117+
if (!cachedConfig) {
118+
if (cachedConfigs.size > CACHE_MAX_SIZE) {
119+
debug('Clearing cache');
120+
cachedConfigs.clear(); // avoid a memory leak
121+
}
122+
123+
debug(`LOADING CONFIG: ${configFilePath}`);
124+
cachedConfig = {
125+
configFile: TSDocConfigFile.loadFile(configFilePath),
126+
lastCheckTimeMs: nowMs,
127+
loadTimeMs: nowMs
128+
};
129+
130+
cachedConfigs.set(cacheKey, cachedConfig);
131+
}
132+
133+
const tsdocConfigFile: TSDocConfigFile = cachedConfig.configFile;
134+
const tsdocConfiguration: TSDocConfiguration = new TSDocConfiguration()
50135

51136
if (!tsdocConfigFile.fileNotFound) {
52137
if (tsdocConfigFile.hasErrors) {

tsdoc-config/src/TSDocConfigFile.ts

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export class TSDocConfigFile {
5959
private readonly _extendsFiles: TSDocConfigFile[];
6060
private _filePath: string;
6161
private _fileNotFound: boolean;
62+
private _fileMTime: number;
6263
private _hasErrors: boolean;
6364
private _tsdocSchema: string;
6465
private readonly _extendsPaths: string[];
@@ -71,6 +72,7 @@ export class TSDocConfigFile {
7172
this._filePath = '';
7273
this._fileNotFound = true;
7374
this._hasErrors = false;
75+
this._fileMTime = 0;
7476
this._tsdocSchema = '';
7577
this._extendsPaths = [];
7678
this._tagDefinitions= [];
@@ -130,14 +132,57 @@ export class TSDocConfigFile {
130132
return this._tagDefinitions;
131133
}
132134

135+
/**
136+
* This can be used for cache eviction. It returns true if the modification timestamp has changed for
137+
* any of the files that were read when loading this `TSDocConfigFile`, which indicates that the file should be
138+
* reloaded. It does not consider cases where `TSDocConfigFile.fileNotFound` was `true`.
139+
*
140+
* @remarks
141+
* This can be used for cache eviction. An example eviction strategy might be like this:
142+
*
143+
* - call `checkForModifiedFiles()` once per second, and reload the configuration if it returns true
144+
*
145+
* - otherwise, reload the configuration when it is more than 10 seconds old (to handle less common cases such
146+
* as creation of a missing file, or creation of a file at an earlier location in the search path).
147+
*/
148+
public checkForModifiedFiles(): boolean {
149+
if (this._checkForModifiedFile()) {
150+
return true;
151+
}
152+
for (const extendsFile of this.extendsFiles) {
153+
if (extendsFile.checkForModifiedFiles()) {
154+
return true;
155+
}
156+
}
157+
return false;
158+
}
159+
160+
/**
161+
* Checks the last modification time for `TSDocConfigFile.filePath` and returns `true` if it has changed
162+
* since the file was loaded. If the file is missing, this returns `false`. If the timestamp cannot be read,
163+
* then this returns `true`.
164+
*/
165+
private _checkForModifiedFile(): boolean {
166+
if (this._fileNotFound || !this._filePath) {
167+
return false;
168+
}
169+
170+
try {
171+
const mtimeMs: number = fs.statSync(this._filePath).mtimeMs;
172+
return mtimeMs !== this._fileMTime;
173+
} catch (error) {
174+
return true;
175+
}
176+
}
177+
133178
private _reportError(parserMessageParameters: IParserMessageParameters): void {
134179
this.log.addMessage(new ParserMessage(parserMessageParameters));
135180
this._hasErrors = true;
136181
}
137182

138183
private _loadJsonFile(): void {
139184
const configJsonContent: string = fs.readFileSync(this._filePath).toString();
140-
185+
this._fileMTime = fs.statSync(this._filePath).mtimeMs;
141186
this._fileNotFound = false;
142187

143188
const configJson: IConfigJson = jju.parse(configJsonContent, { mode: 'cjson' });
@@ -248,7 +293,13 @@ export class TSDocConfigFile {
248293
}
249294
}
250295

251-
private static _findConfigPathForFolder(folderPath: string): string {
296+
/**
297+
* For the given folder, look for the relevant tsdoc.json file (if any), and return its path.
298+
*
299+
* @param folderPath - the path to a folder where the search should start
300+
* @returns the (possibly relative) path to tsdoc.json, or an empty string if not found
301+
*/
302+
public static findConfigPathForFolder(folderPath: string): string {
252303
if (folderPath) {
253304
let foundFolder: string = folderPath;
254305
for (;;) {
@@ -276,16 +327,23 @@ export class TSDocConfigFile {
276327
}
277328

278329
/**
279-
* For the given folder, discover the relevant tsdoc.json files (if any), and load them.
330+
* Calls `TSDocConfigFile.findConfigPathForFolder()` to find the relevant tsdoc.json config file, if one exists.
331+
* Then calls `TSDocConfigFile.findConfigPathForFolder()` to return the loaded result.
280332
* @param folderPath - the path to a folder where the search should start
281333
*/
282334
public static loadForFolder(folderPath: string): TSDocConfigFile {
283-
const configFile: TSDocConfigFile = new TSDocConfigFile();
284-
const rootConfigPath: string = TSDocConfigFile._findConfigPathForFolder(folderPath);
335+
const rootConfigPath: string = TSDocConfigFile.findConfigPathForFolder(folderPath);
336+
return TSDocConfigFile.loadForFolder(rootConfigPath);
337+
}
285338

339+
/**
340+
* Loads the specified tsdoc.json and any base files that it refers to using the "extends" option.
341+
* @param tsdocJsonFilePath - the path to the tsdoc.json config file
342+
*/
343+
public static loadFile(tsdocJsonFilePath: string): TSDocConfigFile {
344+
const configFile: TSDocConfigFile = new TSDocConfigFile();
286345
const alreadyVisitedPaths: Set<string> = new Set<string>();
287-
configFile._loadWithExtends(rootConfigPath, undefined, alreadyVisitedPaths);
288-
346+
configFile._loadWithExtends(tsdocJsonFilePath, undefined, alreadyVisitedPaths);
289347
return configFile;
290348
}
291349

0 commit comments

Comments
 (0)