Skip to content

Commit 0c26dc3

Browse files
authored
perf(startup): avoid templateRegistry in awsFiletypes.ts #3962
Problem: The AWS Documents handler (`awsFiletypes.ts`) is triggered frequently. It calls `globals.templateRegistry` which triggers an expensive "Scanning CloudFormation templates..." process. #3510 Solution: Extract the validation logic out of `CloudFormationTemplateRegistry.process()` so that `awsFiletypes.ts` can use it without requesting the full registry.
1 parent cd708b4 commit 0c26dc3

File tree

7 files changed

+114
-62
lines changed

7 files changed

+114
-62
lines changed

src/shared/awsFiletypes.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@ import * as vscode from 'vscode'
77
import * as path from 'path'
88
import * as constants from '../shared/constants'
99
import * as aslFormats from '../stepFunctions/constants/aslFormats'
10-
import * as pathutil from '../shared/utilities/pathUtils'
1110
import * as fsutil from '../shared/filesystemUtilities'
1211
import * as sysutil from '../shared/systemUtilities'
1312
import * as collectionUtil from '../shared/utilities/collectionUtils'
1413
import globals from './extensionGlobals'
1514
import { telemetry } from './telemetry/telemetry'
1615
import { AwsFiletype } from './telemetry/telemetry'
16+
import { CloudFormation } from './cloudformation/cloudformation'
1717

1818
/** AWS filetypes: vscode language ids */
1919
export const awsFiletypeLangIds = {
@@ -67,9 +67,12 @@ export function activate(): void {
6767
vscode.workspace.onDidOpenTextDocument(async (doc: vscode.TextDocument) => {
6868
const isAwsFileExt = isAwsFiletype(doc)
6969
const isSchemaHandled = globals.schemaService.isMapped(doc.uri)
70-
const isCfnTemplate = !!(await globals.templateRegistry).items.find(
71-
t => pathutil.normalize(t.path) === pathutil.normalize(doc.fileName)
72-
)
70+
const cfnTemplate =
71+
CloudFormation.isValidFilename(doc.uri) && doc.languageId === 'yaml'
72+
? await CloudFormation.tryLoad(doc.uri)
73+
: undefined
74+
const isCfnTemplate = cfnTemplate?.template !== undefined
75+
7376
if (!isAwsFileExt && !isSchemaHandled && !isCfnTemplate) {
7477
return
7578
}
@@ -78,11 +81,12 @@ export function activate(): void {
7881
let fileExt: string | undefined = path.extname(doc.fileName)
7982
fileExt = fileExt ? fileExt : undefined // Telemetry client will fail on empty string.
8083

81-
// TODO: ask templateRegistry if this is SAM or CFN.
8284
// TODO: ask schemaService for the precise filetype.
8385
let telemKind = isAwsConfig(doc.fileName) ? 'awsCredentials' : langidToAwsFiletype(doc.languageId)
84-
if (telemKind === 'other') {
85-
telemKind = isCfnTemplate ? 'cloudformationSam' : isSchemaHandled ? 'cloudformation' : 'other'
86+
if (isCfnTemplate) {
87+
telemKind = cfnTemplate.kind === 'sam' ? 'cloudformationSam' : 'cloudformation'
88+
} else if (telemKind === 'other') {
89+
telemKind = isSchemaHandled ? 'cloudformation' : 'other'
8690
}
8791

8892
// HACK: for "~/.aws/foo" vscode sometimes _only_ emits "~/.aws/foo.git".

src/shared/cloudformation/activation.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,11 @@ import { isToolkitActive, localize } from '../utilities/vsCodeUtils'
1010
import { AsyncCloudFormationTemplateRegistry, CloudFormationTemplateRegistry } from '../fs/templateRegistry'
1111
import { getIdeProperties } from '../extensionUtilities'
1212
import { NoopWatcher } from '../fs/watchedFiles'
13-
import { createStarterTemplateFile } from './cloudformation'
13+
import { CloudFormation, createStarterTemplateFile } from './cloudformation'
1414
import { Commands } from '../vscode/commands2'
1515
import globals from '../extensionGlobals'
1616
import { SamCliSettings } from '../sam/cli/samCliSettings'
1717

18-
export const templateFileGlobPattern = '**/*.{yaml,yml}'
19-
20-
/**
21-
* Match any file path that contains a .aws-sam folder. The way this works is:
22-
* match anything that starts with a '/' or '\', then '.aws-sam', then either
23-
* a '/' or '\' followed by any number of characters or end of a string (so it
24-
* matches both /.aws-sam or /.aws-sam/<any number of characters>)
25-
*/
26-
export const templateFileExcludePattern = /.*[/\\]\.aws-sam([/\\].*|$)/
27-
28-
export const devfileExcludePattern = /.*devfile\.(yaml|yml)/i
29-
3018
/**
3119
* Creates a CloudFormationTemplateRegistry which retains the state of CloudFormation templates in a workspace.
3220
* This also assigns a FileSystemWatcher which will update the registry on any change to tracked templates.
@@ -68,9 +56,9 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi
6856
*/
6957
function setTemplateRegistryInGlobals(registry: CloudFormationTemplateRegistry) {
7058
const registrySetupFunc = async (registry: CloudFormationTemplateRegistry) => {
71-
await registry.addExcludedPattern(devfileExcludePattern)
72-
await registry.addExcludedPattern(templateFileExcludePattern)
73-
await registry.addWatchPatterns([templateFileGlobPattern])
59+
await registry.addExcludedPattern(CloudFormation.devfileExcludePattern)
60+
await registry.addExcludedPattern(CloudFormation.templateFileExcludePattern)
61+
await registry.addWatchPatterns([CloudFormation.templateFileGlobPattern])
7462
await registry.watchUntitledFiles()
7563

7664
return registry

src/shared/cloudformation/cloudformation.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,23 @@ import { SystemUtilities } from '../systemUtilities'
1313
import { getLogger } from '../logger'
1414
import { lambdaPackageTypeImage } from '../constants'
1515
import { isCloud9 } from '../extensionUtilities'
16+
import { isUntitledScheme, normalizeVSCodeUri } from '../utilities/vsCodeUtils'
1617

1718
export namespace CloudFormation {
1819
export const SERVERLESS_API_TYPE = 'AWS::Serverless::Api' // eslint-disable-line @typescript-eslint/naming-convention
1920
export const SERVERLESS_FUNCTION_TYPE = 'AWS::Serverless::Function' // eslint-disable-line @typescript-eslint/naming-convention
2021
export const LAMBDA_FUNCTION_TYPE = 'AWS::Lambda::Function' // eslint-disable-line @typescript-eslint/naming-convention
2122

23+
export const templateFileGlobPattern = '**/*.{yaml,yml}'
24+
export const devfileExcludePattern = /.*devfile\.(yaml|yml)/i
25+
/**
26+
* Match any file path that contains a .aws-sam folder. The way this works is:
27+
* match anything that starts with a '/' or '\', then '.aws-sam', then either
28+
* a '/' or '\' followed by any number of characters or end of a string (so it
29+
* matches both /.aws-sam or /.aws-sam/<any number of characters>)
30+
*/
31+
export const templateFileExcludePattern = /.*[/\\]\.aws-sam([/\\].*|$)/
32+
2233
export function isZipLambdaResource(
2334
resource?: ZipResourceProperties | ImageResourceProperties
2435
): resource is ZipResourceProperties {
@@ -364,6 +375,22 @@ export namespace CloudFormation {
364375
[key: string]: Resource | undefined
365376
}
366377

378+
/** Returns true if the given name or path is a valid CloudFormation or SAM filename. */
379+
export function isValidFilename(filename: string | vscode.Uri): boolean {
380+
filename = typeof filename === 'string' ? filename : filename.fsPath
381+
filename = filename.trim()
382+
if (!filename.endsWith('.yml') && !filename.endsWith('.yaml')) {
383+
return false
384+
}
385+
// Note: intentionally _not_ checking `templateFileExcludePattern` here, because while excluding
386+
// template files in .aws-sam/ is relevant for the workspace scan, it's irrelevant if such
387+
// a file was opened explicitly by the user.
388+
if (filename.match(devfileExcludePattern)) {
389+
return false
390+
}
391+
return true
392+
}
393+
367394
export async function load(filename: string, validate: boolean = true): Promise<Template> {
368395
if (!(await SystemUtilities.fileExists(filename))) {
369396
throw new Error(`Template file not found: ${filename}`)
@@ -384,6 +411,51 @@ export namespace CloudFormation {
384411
return template
385412
}
386413

414+
/**
415+
* Returns a `CloudFormation.Template` if the given file (on disk) or `contents` is a valid
416+
* CloudFormation document, or `{ template: undefined, kind: undefined }` if the file is
417+
* invalid.
418+
*/
419+
export async function tryLoad(
420+
uri: vscode.Uri,
421+
contents?: string
422+
): Promise<{ template: CloudFormation.Template | undefined; kind: 'sam' | 'cfn' | undefined }> {
423+
const rv: {
424+
template: CloudFormation.Template | undefined
425+
kind: 'sam' | 'cfn' | undefined
426+
} = { template: undefined, kind: undefined }
427+
try {
428+
if (isUntitledScheme(uri)) {
429+
if (!contents) {
430+
// this error technically just throw us into the catch so the error message isn't used
431+
throw new Error('Contents must be defined for untitled uris')
432+
}
433+
rv.template = await CloudFormation.loadByContents(contents, false)
434+
} else {
435+
rv.template = await CloudFormation.load(normalizeVSCodeUri(uri), false)
436+
}
437+
} catch (e) {
438+
return {
439+
template: undefined,
440+
kind: undefined,
441+
}
442+
}
443+
444+
// Check if the template is a SAM template, using the same heuristic as the cfn-lint team:
445+
// https://github.com/aws-cloudformation/aws-cfn-lint-visual-studio-code/blob/629de0bac4f36cfc6534e409a6f6766a2240992f/client/src/yaml-support/yaml-schema.ts#L39-L51
446+
if (rv.template.AWSTemplateFormatVersion || rv.template.Resources) {
447+
rv.kind =
448+
rv.template.Transform && rv.template.Transform.toString().startsWith('AWS::Serverless') ? 'sam' : 'cfn'
449+
450+
return rv
451+
}
452+
453+
return {
454+
template: undefined,
455+
kind: undefined,
456+
}
457+
}
458+
387459
export async function save(template: Template, filename: string): Promise<void> {
388460
const templateAsYaml: string = yaml.dump(template)
389461

src/shared/fs/templateRegistry.ts

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,50 +14,30 @@ import { getLambdaDetails } from '../../lambda/utils'
1414
import { WatchedFiles, WatchedItem } from './watchedFiles'
1515
import { getLogger } from '../logger'
1616
import globals from '../extensionGlobals'
17-
import { isUntitledScheme, normalizeVSCodeUri } from '../utilities/vsCodeUtils'
1817
import { sleep } from '../utilities/timeoutUtils'
1918
import { localize } from '../utilities/vsCodeUtils'
2019
import { PerfLog } from '../logger/logger'
2120

2221
export class CloudFormationTemplateRegistry extends WatchedFiles<CloudFormation.Template> {
2322
protected name: string = 'CloudFormationTemplateRegistry'
23+
2424
protected async process(uri: vscode.Uri, contents?: string): Promise<CloudFormation.Template | undefined> {
2525
// P0: Assume all template.yaml/yml files are CFN templates and assign correct JSON schema.
2626
// P1: Alter registry functionality to search ALL YAML files and apply JSON schemas + add to registry based on validity
2727

28-
let template: CloudFormation.Template | undefined
29-
const path = normalizeVSCodeUri(uri)
30-
try {
31-
if (isUntitledScheme(uri)) {
32-
if (!contents) {
33-
// this error technically just throw us into the catch so the error message isn't used
34-
throw new Error('Contents must be defined for untitled uris')
35-
}
36-
template = await CloudFormation.loadByContents(contents, false)
37-
} else {
38-
template = await CloudFormation.load(path, false)
39-
}
40-
} catch (e) {
28+
const r = await CloudFormation.tryLoad(uri, contents)
29+
if (r.kind === undefined) {
4130
globals.schemaService.registerMapping({ uri, type: 'yaml', schema: undefined })
4231
return undefined
4332
}
4433

45-
// same heuristic used by cfn-lint team:
46-
// https://github.com/aws-cloudformation/aws-cfn-lint-visual-studio-code/blob/629de0bac4f36cfc6534e409a6f6766a2240992f/client/src/yaml-support/yaml-schema.ts#L39-L51
47-
if (template.AWSTemplateFormatVersion || template.Resources) {
48-
if (template.Transform && template.Transform.toString().startsWith('AWS::Serverless')) {
49-
// apply serverless schema
50-
globals.schemaService.registerMapping({ uri, type: 'yaml', schema: 'sam' })
51-
} else {
52-
// apply cfn schema
53-
globals.schemaService.registerMapping({ uri, type: 'yaml', schema: 'cfn' })
54-
}
55-
56-
return template
34+
if (r.kind === 'sam') {
35+
globals.schemaService.registerMapping({ uri, type: 'yaml', schema: 'sam' })
36+
} else if (r.kind === 'cfn') {
37+
globals.schemaService.registerMapping({ uri, type: 'yaml', schema: 'cfn' })
5738
}
5839

59-
globals.schemaService.registerMapping({ uri, type: 'yaml', schema: undefined })
60-
return undefined
40+
return r.template
6141
}
6242

6343
// handles delete case
@@ -123,7 +103,7 @@ export class AsyncCloudFormationTemplateRegistry {
123103
location: vscode.ProgressLocation.Notification,
124104
title: localize(
125105
'AWS.codelens.waitingForTemplateRegistry',
126-
'Scanning CloudFormation templates... (except paths configured in [search.exclude](command:workbench.action.openSettings?"@id:search.exclude"))'
106+
'Scanning CloudFormation templates (except [search.exclude](command:workbench.action.openSettings?"@id:search.exclude") paths)...'
127107
),
128108
cancellable: true,
129109
},

src/test/shared/awsFiletypes.test.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ describe('awsFiletypes', function () {
3939
})
4040

4141
it('emit telemetry when opened by user', async function () {
42-
await (await globals.templateRegistry).addItem(cfnUri!)
4342
await vscode.commands.executeCommand('vscode.open', cfnUri)
4443
await vscode.commands.executeCommand('vscode.open', awsConfigUri)
4544
await vscode.workspace.openTextDocument({
@@ -61,16 +60,13 @@ describe('awsFiletypes', function () {
6160

6261
assert(r, 'did not emit expected telemetry')
6362
assert(r.length === 3, 'emitted file_editAwsFile too many times')
64-
const m1filetype = r[0].Metadata?.find(o => o.Key === 'awsFiletype')?.Value
65-
const m2filetype = r[1].Metadata?.find(o => o.Key === 'awsFiletype')?.Value
66-
const m3filetype = r[2].Metadata?.find(o => o.Key === 'awsFiletype')?.Value
67-
assert.strictEqual(m1filetype, 'cloudformationSam')
68-
assert.strictEqual(m2filetype, 'awsCredentials')
69-
assert.strictEqual(m3filetype, 'ssmDocument')
63+
const metrics = r.map(o => o.Metadata?.find(o => o.Key === 'awsFiletype')?.Value)
64+
// The order is arbitrary (decided by vscode event system).
65+
metrics.sort()
66+
assert.deepStrictEqual(metrics, ['awsCredentials', 'cloudformationSam', 'ssmDocument'])
7067
})
7168

7269
it('emit telemetry exactly once per filetype in a given flush window', async function () {
73-
await (await globals.templateRegistry).addItem(cfnUri!)
7470
await vscode.commands.executeCommand('vscode.open', cfnUri)
7571
async function getMetrics() {
7672
return await waitUntil(

src/test/shared/cloudformation/cloudformation.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ describe('CloudFormation', function () {
3434
await fs.remove(filename)
3535
})
3636

37+
it('isValidFilename()', async function () {
38+
assert.deepStrictEqual(CloudFormation.isValidFilename('/foo/bar.yaml'), true)
39+
assert.deepStrictEqual(CloudFormation.isValidFilename('/foo/template.yaml'), true)
40+
assert.deepStrictEqual(CloudFormation.isValidFilename('template.yaml'), true)
41+
assert.deepStrictEqual(CloudFormation.isValidFilename('template.yml'), true)
42+
assert.deepStrictEqual(CloudFormation.isValidFilename('/.aws-sam/template.yaml'), true)
43+
assert.deepStrictEqual(CloudFormation.isValidFilename('devfile.yml'), false)
44+
assert.deepStrictEqual(CloudFormation.isValidFilename('devfile.yaml'), false)
45+
assert.deepStrictEqual(CloudFormation.isValidFilename('template.yml.bk'), false)
46+
assert.deepStrictEqual(CloudFormation.isValidFilename('template.txt'), false)
47+
})
48+
3749
describe('load', async function () {
3850
it('can successfully load a file', async function () {
3951
const yamlStr = makeSampleSamTemplateYaml(true)

src/test/shared/debug/launchConfiguration.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import { AwsSamDebuggerConfiguration } from '../../../shared/sam/debugger/awsSam
1717
import { AwsSamDebugConfigurationValidator } from '../../../shared/sam/debugger/awsSamDebugConfigurationValidator'
1818
import * as pathutils from '../../../shared/utilities/pathUtils'
1919
import * as testutil from '../../testUtil'
20-
import { templateFileGlobPattern } from '../../../shared/cloudformation/activation'
2120
import globals from '../../../shared/extensionGlobals'
21+
import { CloudFormation } from '../../../shared/cloudformation/cloudformation'
2222

2323
const samDebugConfiguration: AwsSamDebuggerConfiguration = {
2424
type: 'aws-sam',
@@ -106,7 +106,7 @@ describe('LaunchConfiguration', function () {
106106

107107
beforeEach(async function () {
108108
const registry = await globals.templateRegistry
109-
await registry.addWatchPatterns([templateFileGlobPattern])
109+
await registry.addWatchPatterns([CloudFormation.templateFileGlobPattern])
110110

111111
// TODO: remove mocks in favor of testing src/testFixtures/ data.
112112
mockConfigSource = mock()

0 commit comments

Comments
 (0)