Skip to content

Commit c214178

Browse files
authored
Speed up autocomplete intrinsic fns (#230)
1 parent 92f035c commit c214178

16 files changed

+282
-122
lines changed

src/autocomplete/CompletionRouter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export class CompletionRouter implements SettingsConfigurable, Closeable {
6464
const triggerChar = params.context?.triggerCharacter ?? '';
6565

6666
// Check for intrinsic function argument completions first
67-
if (context.intrinsicContext.inIntrinsic()) {
67+
if (context.intrinsicContext.inIntrinsic() && !this.shouldUseIntrinsicFunctionProvider(context)) {
6868
const doc = this.completionProviderMap.get('IntrinsicFunctionArgument')?.getCompletions(context, params);
6969

7070
if (doc && !(doc instanceof Promise) && doc.length > 0) {

src/autocomplete/IntrinsicFunctionArgumentCompletionProvider.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -279,23 +279,9 @@ export class IntrinsicFunctionArgumentCompletionProvider implements CompletionPr
279279

280280
private getResourceAttributes(resourceType: string): string[] {
281281
const schema = this.schemaRetriever.getDefault().schemas.get(resourceType);
282-
if (!schema?.readOnlyProperties || schema.readOnlyProperties.length === 0) {
283-
return [];
284-
}
282+
if (!schema) return [];
285283

286-
return schema.readOnlyProperties
287-
.map((propertyPath) => {
288-
const match = propertyPath.match(/^\/properties\/(.+)$/);
289-
return match ? match[1].replaceAll('/', '.') : undefined;
290-
})
291-
.filter((attr): attr is string => attr !== undefined)
292-
.filter((attr) => {
293-
const lastDotIndex = attr.lastIndexOf('.');
294-
if (lastDotIndex === -1) return true;
295-
const pathWithoutLastSegment = attr.slice(0, Math.max(0, lastDotIndex));
296-
return !pathWithoutLastSegment.includes('*');
297-
})
298-
.filter((attr, index, array) => array.indexOf(attr) === index);
284+
return schema.getAttributes().map((attr) => attr.name);
299285
}
300286

301287
private getGetAttCompletions(syntaxTree: SyntaxTree, currentLogicalId?: string): CompletionItem[] {

src/schema/GetSchemaTaskManager.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export class GetSchemaTaskManager implements SettingsConfigurable, Closeable {
2929
private readonly getPublicSchemas: (region: AwsRegion) => Promise<SchemaFileType[]>,
3030
private readonly getPrivateResources: () => Promise<DescribeTypeOutput[]>,
3131
private profile: string = DefaultSettings.profile.profile,
32+
private readonly onSchemaUpdate: (region?: string, profile?: string) => void,
3233
) {
3334
this.privateTask = new GetPrivateSchemasTask(this.getPrivateResources, () => this.profile);
3435
this.samTask = new GetSamSchemaTask();
@@ -73,14 +74,18 @@ export class GetSchemaTaskManager implements SettingsConfigurable, Closeable {
7374
runPrivateTask() {
7475
this.privateTask
7576
.run(this.schemas.privateSchemas, this.log)
76-
.then(() => this.schemas.invalidateCombinedSchemas())
77+
.then(() => {
78+
this.onSchemaUpdate(undefined, this.profile);
79+
})
7780
.catch(() => {});
7881
}
7982

8083
runSamTask() {
8184
this.samTask
8285
.run(this.schemas.samSchemas, this.log)
83-
.then(() => this.schemas.invalidateCombinedSchemas())
86+
.then(() => {
87+
this.onSchemaUpdate(); // No params = SAM update
88+
})
8489
.catch(() => {});
8590
}
8691

@@ -99,7 +104,9 @@ export class GetSchemaTaskManager implements SettingsConfigurable, Closeable {
99104
const task = this.tasks.shift();
100105
if (task) {
101106
task.run(this.schemas.publicSchemas, this.log)
102-
.then(() => this.schemas.invalidateCombinedSchemas())
107+
.then(() => {
108+
this.onSchemaUpdate(task.region);
109+
})
103110
.catch(() => {
104111
this.tasks.push(task);
105112
})

src/schema/ResourceSchema.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export class ResourceSchema {
4848
public readonly writeOnlyProperties?: string[];
4949
public readonly createOnlyProperties?: string[];
5050
public readonly deprecatedProperties?: string[];
51+
private _attributes?: Array<{ name: string; description: string }>;
5152
public readonly conditionalCreateOnlyProperties?: string[];
5253
public readonly nonPublicProperties?: string[];
5354
public readonly nonPublicDefinitions?: string[];
@@ -1041,6 +1042,42 @@ export class ResourceSchema {
10411042
...(this.oneOf && { oneOf: this.oneOf as PropertyType[] }),
10421043
};
10431044
}
1045+
1046+
public getAttributes(): Array<{ name: string; description: string }> {
1047+
this._attributes ??= this.computeAttributes();
1048+
return this._attributes;
1049+
}
1050+
1051+
private computeAttributes(): Array<{ name: string; description: string }> {
1052+
if (!this.readOnlyProperties) return [];
1053+
1054+
return this.readOnlyProperties
1055+
.filter((prop) => !prop.includes('/*/'))
1056+
.map((prop) => {
1057+
const match = prop.match(/^\/properties\/(.+)$/);
1058+
if (!match) return;
1059+
1060+
const name = match[1].replaceAll('/', '.');
1061+
const description = this.getAttributeDescription(prop);
1062+
return { name, description };
1063+
})
1064+
.filter((attr): attr is { name: string; description: string } => attr !== undefined);
1065+
}
1066+
1067+
private getAttributeDescription(propertyPath: string): string {
1068+
try {
1069+
const resolvedSchemas = this.resolveJsonPointerPath(propertyPath);
1070+
if (resolvedSchemas.length > 0 && resolvedSchemas[0].description) {
1071+
return resolvedSchemas[0].description;
1072+
}
1073+
} catch {
1074+
// Fall back to default description
1075+
}
1076+
1077+
const match = propertyPath.match(/^\/properties\/(.+)$/);
1078+
const attributeName = match ? match[1].replaceAll('/', '.') : propertyPath;
1079+
return `${attributeName} attribute of ${this.typeName}`;
1080+
}
10441081
}
10451082

10461083
export type PropertyType = {

src/schema/SchemaRetriever.ts

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1+
import { DescribeTypeOutput } from '@aws-sdk/client-cloudformation';
12
import { DateTime } from 'luxon';
23
import { SettingsConfigurable, ISettingsSubscriber, SettingsSubscription } from '../settings/ISettingsSubscriber';
34
import { DefaultSettings, ProfileSettings } from '../settings/Settings';
45
import { LoggerFactory } from '../telemetry/LoggerFactory';
56
import { ScopedTelemetry } from '../telemetry/ScopedTelemetry';
6-
import { Telemetry } from '../telemetry/TelemetryDecorator';
7+
import { Telemetry, Measure } from '../telemetry/TelemetryDecorator';
78
import { Closeable } from '../utils/Closeable';
89
import { AwsRegion, getRegion } from '../utils/Region';
910
import { CombinedSchemas } from './CombinedSchemas';
1011
import { GetSchemaTaskManager } from './GetSchemaTaskManager';
11-
import { RegionalSchemasType } from './RegionalSchemas';
12+
import { PrivateSchemasType } from './PrivateSchemas';
13+
import { RegionalSchemasType, SchemaFileType } from './RegionalSchemas';
1214
import { SamSchemasType, SamStoreKey } from './SamSchemas';
1315
import { SchemaStore } from './SchemaStore';
1416

@@ -19,14 +21,24 @@ export class SchemaRetriever implements SettingsConfigurable, Closeable {
1921
private settingsSubscription?: SettingsSubscription;
2022
private settings: ProfileSettings = DefaultSettings.profile;
2123
private readonly log = LoggerFactory.getLogger(SchemaRetriever);
24+
private readonly schemaTaskManager: GetSchemaTaskManager;
2225

2326
@Telemetry()
2427
private readonly telemetry!: ScopedTelemetry;
2528

2629
constructor(
27-
private readonly schemaTaskManager: GetSchemaTaskManager,
2830
private readonly schemaStore: SchemaStore,
31+
private readonly getPublicSchemas: (region: AwsRegion) => Promise<SchemaFileType[]>,
32+
private readonly getPrivateResources: () => Promise<DescribeTypeOutput[]>,
2933
) {
34+
this.schemaTaskManager = new GetSchemaTaskManager(
35+
this.schemaStore,
36+
this.getPublicSchemas,
37+
this.getPrivateResources,
38+
this.settings.profile,
39+
(region, profile) => this.rebuildAffectedCombinedSchemas(region, profile),
40+
);
41+
3042
this.telemetry.registerGaugeProvider('schema.public.maxAge', () => this.getPublicSchemaMaxAge(), {
3143
unit: 'ms',
3244
});
@@ -84,15 +96,31 @@ export class SchemaRetriever implements SettingsConfigurable, Closeable {
8496
return this.get(this.settings.region, this.settings.profile);
8597
}
8698

99+
@Measure({ name: 'getSchemas' })
87100
get(region: AwsRegion, profile: string): CombinedSchemas {
101+
// Check if combined schemas are already cached first
102+
const cacheKey = `${region}:${profile}`;
103+
const cachedCombined = this.schemaStore.combinedSchemas.get<CombinedSchemas>(cacheKey);
104+
105+
if (cachedCombined) {
106+
return cachedCombined;
107+
}
108+
109+
// Only do expensive regional check if no cached combined schemas
88110
const regionalSchemas = this.getRegionalSchemasFromStore(region);
89111
if (regionalSchemas) {
90112
this.availableRegions.add(region);
91113
} else {
92114
this.schemaTaskManager.addTask(region);
93115
}
94116

95-
return this.schemaStore.getCombinedSchemas(region, profile);
117+
// Create and cache combined schemas
118+
const privateSchemas = this.schemaStore.privateSchemas.get<PrivateSchemasType>(profile);
119+
const samSchemas = this.schemaStore.samSchemas.get<SamSchemasType>(SamStoreKey);
120+
const combinedSchemas = CombinedSchemas.from(regionalSchemas, privateSchemas, samSchemas);
121+
122+
void this.schemaStore.combinedSchemas.put(cacheKey, combinedSchemas);
123+
return combinedSchemas;
96124
}
97125

98126
updatePrivateSchemas() {
@@ -105,6 +133,34 @@ export class SchemaRetriever implements SettingsConfigurable, Closeable {
105133
this.schemaStore.invalidateCombinedSchemas();
106134
}
107135

136+
// Proactively rebuild combined schemas to avoid lazy loading delays
137+
@Measure({ name: 'rebuildCurrentSchemas' })
138+
rebuildForCurrentSettings() {
139+
this.schemaStore.invalidateCombinedSchemas();
140+
this.get(this.settings.region, this.settings.profile);
141+
}
142+
143+
// Surgically rebuild affected combined schemas
144+
@Measure({ name: 'rebuildAffectedSchemas' })
145+
rebuildAffectedCombinedSchemas(updatedRegion?: string, updatedProfile?: string) {
146+
if (!updatedRegion && !updatedProfile) {
147+
// SAM update - affects all schemas
148+
this.schemaStore.invalidateCombinedSchemas();
149+
this.rebuildForCurrentSettings();
150+
return;
151+
}
152+
153+
const keys = this.schemaStore.combinedSchemas.keys(1000);
154+
for (const key of keys) {
155+
const [region, profile] = key.split(':');
156+
if ((updatedRegion && region === updatedRegion) || (updatedProfile && profile === updatedProfile)) {
157+
// Invalidate and rebuild this specific combined schema
158+
void this.schemaStore.combinedSchemas.remove(key);
159+
this.get(region as AwsRegion, profile);
160+
}
161+
}
162+
}
163+
108164
private getRegionalSchemasFromStore(region: AwsRegion) {
109165
return this.schemaStore.publicSchemas.get<RegionalSchemasType>(region);
110166
}

src/schema/SchemaStore.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
11
import { DataStoreFactoryProvider, Persistence } from '../datastore/DataStore';
2-
import { AwsRegion } from '../utils/Region';
3-
import { CombinedSchemas } from './CombinedSchemas';
4-
import { PrivateSchemasType } from './PrivateSchemas';
5-
import { RegionalSchemasType } from './RegionalSchemas';
6-
import { SamSchemasType, SamStoreKey } from './SamSchemas';
72

83
export class SchemaStore {
94
public readonly publicSchemas = this.dataStoreFactory.get('public_schemas', Persistence.local);
@@ -13,22 +8,6 @@ export class SchemaStore {
138

149
constructor(private readonly dataStoreFactory: DataStoreFactoryProvider) {}
1510

16-
getCombinedSchemas(region: AwsRegion, profile: string): CombinedSchemas {
17-
const cacheKey = `${region}:${profile}`;
18-
let cached = this.combinedSchemas.get<CombinedSchemas>(cacheKey);
19-
20-
if (!cached) {
21-
const regionalSchemas = this.publicSchemas.get<RegionalSchemasType>(region);
22-
const privateSchemas = this.privateSchemas.get<PrivateSchemasType>(profile);
23-
const samSchemas = this.samSchemas.get<SamSchemasType>(SamStoreKey);
24-
25-
cached = CombinedSchemas.from(regionalSchemas, privateSchemas, samSchemas);
26-
void this.combinedSchemas.put(cacheKey, cached);
27-
}
28-
29-
return cached;
30-
}
31-
3211
invalidateCombinedSchemas() {
3312
void this.combinedSchemas.clear();
3413
}

src/server/CfnExternal.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { FeatureFlagProvider } from '../featureFlag/FeatureFlagProvider';
22
import { LspComponents } from '../protocol/LspComponents';
33
import { getRemotePrivateSchemas, getRemotePublicSchemas } from '../schema/GetSchemaTask';
4-
import { GetSchemaTaskManager } from '../schema/GetSchemaTaskManager';
54
import { SchemaRetriever } from '../schema/SchemaRetriever';
65
import { SchemaStore } from '../schema/SchemaStore';
76
import { AwsClient } from '../services/AwsClient';
@@ -28,7 +27,6 @@ export class CfnExternal implements Configurables, Closeable {
2827
readonly s3Service: S3Service;
2928

3029
readonly schemaStore: SchemaStore;
31-
readonly schemaTaskManager: GetSchemaTaskManager;
3230
readonly schemaRetriever: SchemaRetriever;
3331

3432
readonly cfnLintService: CfnLintService;
@@ -46,13 +44,11 @@ export class CfnExternal implements Configurables, Closeable {
4644
this.s3Service = overrides.s3Service ?? new S3Service(this.awsClient);
4745

4846
this.schemaStore = overrides.schemaStore ?? new SchemaStore(core.dataStoreFactory);
49-
this.schemaTaskManager =
50-
overrides.schemaTaskManager ??
51-
new GetSchemaTaskManager(this.schemaStore, getRemotePublicSchemas, () => {
52-
return getRemotePrivateSchemas(core.awsCredentials, this.cfnService);
53-
});
5447
this.schemaRetriever =
55-
overrides.schemaRetriever ?? new SchemaRetriever(this.schemaTaskManager, this.schemaStore);
48+
overrides.schemaRetriever ??
49+
new SchemaRetriever(this.schemaStore, getRemotePublicSchemas, () =>
50+
getRemotePrivateSchemas(core.awsCredentials, this.cfnService),
51+
);
5652

5753
this.cfnLintService =
5854
overrides.cfnLintService ??
@@ -66,14 +62,13 @@ export class CfnExternal implements Configurables, Closeable {
6662
}
6763

6864
configurables(): Configurable[] {
69-
return [this.schemaTaskManager, this.schemaRetriever, this.cfnLintService, this.guardService];
65+
return [this.schemaRetriever, this.cfnLintService, this.guardService];
7066
}
7167

7268
async close() {
7369
return await closeSafely(
7470
this.cfnLintService,
7571
this.guardService,
76-
this.schemaTaskManager,
7772
this.schemaRetriever,
7873
this.onlineStatus,
7974
this.featureFlags,

tools/telemetry-generator.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,6 @@ function main() {
200200
const schemaStore = new SchemaStore(dataStoreFactory);
201201
const external = new CfnExternal(lsp, core, {
202202
schemaStore,
203-
schemaTaskManager: new GetSchemaTaskManager(schemaStore, getRemotePublicSchemas, () =>
204-
Promise.resolve(getTestPrivateSchemas()),
205-
),
206203
featureFlags: new FeatureFlagProvider(join(__dirname, '..', 'assets', 'featureFlag', 'alpha.json')),
207204
});
208205

tst/unit/autocomplete/IntrinsicFunctionArgumentCompletionProvider.GetAtt.test.ts

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -629,51 +629,6 @@ describe('IntrinsicFunctionArgumentCompletionProvider - GetAtt Function', () =>
629629

630630
resolveJsonPointerPathSpy.mockRestore();
631631
});
632-
633-
it('should remove duplicate attribute names', () => {
634-
// Setup mock schema with duplicates
635-
const duplicateSchemaJson = JSON.stringify({
636-
typeName: 'AWS::S3::Bucket',
637-
description: 'Mock S3 Bucket schema',
638-
additionalProperties: false,
639-
primaryIdentifier: ['/properties/BucketName'],
640-
properties: {},
641-
readOnlyProperties: [
642-
'/properties/Arn',
643-
'/properties/Arn', // Duplicate
644-
'/properties/DomainName',
645-
'/properties/MetadataTableConfiguration/S3TablesDestination/TableNamespace',
646-
'/properties/MetadataTableConfiguration/S3TablesDestination/TableArn', // Same top-level
647-
],
648-
});
649-
const duplicateSchema = new ResourceSchema(duplicateSchemaJson);
650-
651-
const duplicateSchemas = new Map([['AWS::S3::Bucket', duplicateSchema]]);
652-
const duplicateCombinedSchemas = new CombinedSchemas();
653-
(duplicateCombinedSchemas as any).schemas = duplicateSchemas;
654-
const duplicateSchemaRetriever = createMockSchemaRetriever(duplicateCombinedSchemas);
655-
656-
provider = new IntrinsicFunctionArgumentCompletionProvider(
657-
mockSyntaxTreeManager,
658-
duplicateSchemaRetriever,
659-
mockDocumentManager,
660-
);
661-
662-
setupResourceEntitiesWithSchema({ MyS3Bucket: { Type: 'AWS::S3::Bucket' } });
663-
664-
const mockContext = createMockGetAttContext('', 'MyS3Bucket.');
665-
666-
const result = provider.getCompletions(mockContext, createTestParams());
667-
668-
expect(result).toBeDefined();
669-
expect(result!.length).toBe(4); // Arn, DomainName, and two nested MetadataTableConfiguration paths (no duplicates)
670-
671-
const labels = result!.map((item) => item.label);
672-
expect(labels).toContain('Arn');
673-
expect(labels).toContain('DomainName');
674-
expect(labels).toContain('MetadataTableConfiguration.S3TablesDestination.TableNamespace');
675-
expect(labels).toContain('MetadataTableConfiguration.S3TablesDestination.TableArn');
676-
});
677632
});
678633
});
679634
});

0 commit comments

Comments
 (0)