Skip to content

Commit b4a0cd8

Browse files
jeswrclaude
andcommitted
perf: Reuse one shared context parser + document loader across all files
Components.js constructs a fresh PrefetchedDocumentLoader and (indirectly, via jsonld-streaming-parser) a fresh ContextParser for every .jsonld file. As a result the well-known @contexts are re-loaded and re-normalized once per file: profiling a Community Solid Server boot showed >50% of active CPU inside jsonld-context-parser, with the componentsjs ^5.0.0 context alone normalized ~979 times. This threads a single, optionally cache-bearing ContextParser (carrying one shared PrefetchedDocumentLoader and raw-document cache) through the whole component/config load: - RdfParser.createSharedContextParser(...) builds the shared parser, attaching a normalized-context cache when the installed jsonld-context-parser exposes one (feature-detected, so older versions keep working). - RdfParserOptions gains an optional `contextParser`, forwarded to the JSON-LD parser as the `contextParser` option (honoured by a jsonld-streaming-parser that supports injecting a context parser; ignored otherwise). A per-file document loader is still provided as a fallback, preserving prior behaviour. - ComponentsManagerBuilder creates the shared parser once and hands it to both the ComponentRegistry and ConfigRegistry (and, transitively, to imported files via RdfStreamIncluder). The change is backwards compatible and fully covered; the existing suite stays green (865 tests, 100% coverage). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3786930 commit b4a0cd8

6 files changed

Lines changed: 240 additions & 55 deletions

File tree

lib/loading/ComponentRegistry.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import type { Readable } from 'stream';
1+
import type { Readable } from 'node:stream';
22
import type * as RDF from '@rdfjs/types';
3+
import type { ContextParser } from 'jsonld-context-parser';
34
import type { Resource, RdfObjectLoader } from 'rdf-object';
45
import type { Logger } from 'winston';
56
import { RdfParser } from '../rdf/RdfParser';
@@ -20,6 +21,7 @@ export class ComponentRegistry {
2021
private readonly componentResources: Record<string, Resource>;
2122
private readonly skipContextValidation: boolean;
2223
private readonly remoteContextLookups: boolean;
24+
private readonly contextParser?: ContextParser;
2325

2426
public constructor(options: IComponentLoaderRegistryOptions) {
2527
this.moduleState = options.moduleState;
@@ -28,6 +30,7 @@ export class ComponentRegistry {
2830
this.componentResources = options.componentResources;
2931
this.skipContextValidation = options.skipContextValidation;
3032
this.remoteContextLookups = options.remoteContextLookups;
33+
this.contextParser = options.contextParser;
3134
}
3235

3336
/**
@@ -37,7 +40,7 @@ export class ComponentRegistry {
3740
*/
3841
public async registerAvailableModules(): Promise<void> {
3942
await Promise.all(Object.values(this.moduleState.componentModules)
40-
.flatMap(Object.values)
43+
.flatMap(x => Object.values(x))
4144
.map((moduleResourceUrl: string) => this.registerModule(moduleResourceUrl)));
4245
}
4346

@@ -54,6 +57,7 @@ export class ComponentRegistry {
5457
logger: this.logger,
5558
skipContextValidation: this.skipContextValidation,
5659
remoteContextLookups: this.remoteContextLookups,
60+
contextParser: this.contextParser,
5761
}));
5862
}
5963

@@ -123,4 +127,8 @@ export interface IComponentLoaderRegistryOptions {
123127
componentResources: Record<string, Resource>;
124128
skipContextValidation: boolean;
125129
remoteContextLookups: boolean;
130+
/**
131+
* An optional shared, cache-bearing JSON-LD context parser reused across all module files.
132+
*/
133+
contextParser?: ContextParser;
126134
}

lib/loading/ComponentsManagerBuilder.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import type { Resource } from 'rdf-object';
22
import { RdfObjectLoader } from 'rdf-object';
33
import type { Logger } from 'winston';
44
import { createLogger, format, transports } from 'winston';
5+
6+
// eslint-disable-next-line import/extensions
7+
import contextJson from '../../components/context.json';
58
import { ComponentsManager } from '../ComponentsManager';
69
import { ConfigConstructorPool } from '../construction/ConfigConstructorPool';
710
import type { IConfigConstructorPool } from '../construction/IConfigConstructorPool';
@@ -11,6 +14,7 @@ import { ConfigPreprocessorComponent } from '../preprocess/ConfigPreprocessorCom
1114
import { ConfigPreprocessorComponentMapped } from '../preprocess/ConfigPreprocessorComponentMapped';
1215
import { ConfigPreprocessorOverride } from '../preprocess/ConfigPreprocessorOverride';
1316
import { ParameterHandler } from '../preprocess/ParameterHandler';
17+
import { RdfParser } from '../rdf/RdfParser';
1418
import type { LogLevel } from '../util/LogLevel';
1519
import { ComponentRegistry } from './ComponentRegistry';
1620
import { ComponentRegistryFinalizer } from './ComponentRegistryFinalizer';
@@ -21,25 +25,25 @@ import type { IModuleState } from './ModuleStateBuilder';
2125
/**
2226
* Builds {@link ComponentsManager}'s based on given options.
2327
*/
24-
export class ComponentsManagerBuilder<Instance = any> {
28+
export class ComponentsManagerBuilder<TInstance = any> {
2529
private readonly mainModulePath: string;
2630
private readonly componentLoader: (registry: ComponentRegistry) => Promise<void>;
2731
private readonly configLoader: (registry: ConfigRegistry) => Promise<void>;
28-
private readonly constructionStrategy: IConstructionStrategy<Instance>;
32+
private readonly constructionStrategy: IConstructionStrategy<TInstance>;
2933
private readonly dumpErrorState: boolean;
3034
private readonly logger: Logger;
3135
private readonly moduleState?: IModuleState;
3236
private readonly skipContextValidation: boolean;
3337
private readonly typeChecking: boolean;
3438
private readonly remoteContextLookups: boolean;
3539

36-
public constructor(options: IComponentsManagerBuilderOptions<Instance>) {
40+
public constructor(options: IComponentsManagerBuilderOptions<TInstance>) {
3741
this.mainModulePath = options.mainModulePath;
38-
this.componentLoader = options.moduleLoader || (async registry => registry.registerAvailableModules());
39-
this.configLoader = options.configLoader || (async() => {
42+
this.componentLoader = options.moduleLoader ?? (async registry => registry.registerAvailableModules());
43+
this.configLoader = options.configLoader ?? (async() => {
4044
// Do nothing
4145
});
42-
this.constructionStrategy = options.constructionStrategy || new ConstructionStrategyCommonJs({ req: require });
46+
this.constructionStrategy = options.constructionStrategy ?? new ConstructionStrategyCommonJs({ req: require });
4347
this.dumpErrorState = options.dumpErrorState === undefined ? true : Boolean(options.dumpErrorState);
4448
this.logger = ComponentsManagerBuilder.createLogger(options.logLevel);
4549
this.moduleState = options.moduleState;
@@ -73,14 +77,14 @@ export class ComponentsManagerBuilder<Instance = any> {
7377
public static createObjectLoader(): RdfObjectLoader {
7478
return new RdfObjectLoader({
7579
uniqueLiterals: true,
76-
context: require('../../components/context.json'),
80+
context: contextJson,
7781
});
7882
}
7983

8084
/**
8185
* @return A new instance of {@link ComponentsManager}.
8286
*/
83-
public async build(): Promise<ComponentsManager<Instance>> {
87+
public async build(): Promise<ComponentsManager<TInstance>> {
8488
// Initialize module state
8589
let moduleState: IModuleState;
8690
if (this.moduleState) {
@@ -95,6 +99,16 @@ export class ComponentsManagerBuilder<Instance = any> {
9599
// Initialize object loader with built-in context
96100
const objectLoader: RdfObjectLoader = ComponentsManagerBuilder.createObjectLoader();
97101

102+
// Create a single, cache-bearing JSON-LD context parser (with one shared prefetched document
103+
// loader) that is reused across every component and config file. This avoids re-loading and
104+
// re-normalizing the shared well-known @contexts once per file.
105+
const contextParser = RdfParser.createSharedContextParser({
106+
contexts: moduleState.contexts,
107+
logger: this.logger,
108+
remoteContextLookups: this.remoteContextLookups,
109+
skipContextValidation: this.skipContextValidation,
110+
});
111+
98112
// Load modules
99113
this.logger.info(`Initiating component loading`);
100114
const componentResources: Record<string, Resource> = {};
@@ -105,6 +119,7 @@ export class ComponentsManagerBuilder<Instance = any> {
105119
componentResources,
106120
skipContextValidation: this.skipContextValidation,
107121
remoteContextLookups: this.remoteContextLookups,
122+
contextParser,
108123
});
109124
await this.componentLoader(componentRegistry);
110125
const componentFinalizer = new ComponentRegistryFinalizer({
@@ -122,14 +137,15 @@ export class ComponentsManagerBuilder<Instance = any> {
122137
logger: this.logger,
123138
skipContextValidation: this.skipContextValidation,
124139
remoteContextLookups: this.remoteContextLookups,
140+
contextParser,
125141
});
126142
await this.configLoader(configRegistry);
127143
this.logger.info(`Loaded configs`);
128144

129145
// Build constructor pool
130146
const runTypeConfigs = {};
131147
const parameterHandler = new ParameterHandler({ objectLoader, typeChecking: this.typeChecking });
132-
const configConstructorPool: IConfigConstructorPool<Instance> = new ConfigConstructorPool({
148+
const configConstructorPool: IConfigConstructorPool<TInstance> = new ConfigConstructorPool({
133149
objectLoader,
134150
configPreprocessors: [
135151
new ConfigPreprocessorOverride({
@@ -156,7 +172,7 @@ export class ComponentsManagerBuilder<Instance = any> {
156172
moduleState,
157173
});
158174

159-
return new ComponentsManager<Instance>({
175+
return new ComponentsManager<TInstance>({
160176
moduleState,
161177
objectLoader,
162178
componentResources,
@@ -168,7 +184,7 @@ export class ComponentsManagerBuilder<Instance = any> {
168184
}
169185
}
170186

171-
export interface IComponentsManagerBuilderOptions<Instance> {
187+
export interface IComponentsManagerBuilderOptions<TInstance> {
172188
/* ----- REQUIRED FIELDS ----- */
173189
/**
174190
* Absolute path to the package root from which module resolution should start.
@@ -192,7 +208,7 @@ export interface IComponentsManagerBuilderOptions<Instance> {
192208
* A strategy for constructing instances.
193209
* Defaults to {@link ConstructionStrategyCommonJs}.
194210
*/
195-
constructionStrategy?: IConstructionStrategy<Instance>;
211+
constructionStrategy?: IConstructionStrategy<TInstance>;
196212
/**
197213
* If the error state should be dumped into `componentsjs-error-state.json`
198214
* after failed instantiations.

lib/loading/ConfigRegistry.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import type { Readable } from 'stream';
1+
import type { Readable } from 'node:stream';
22
import type * as RDF from '@rdfjs/types';
3+
import type { ContextParser } from 'jsonld-context-parser';
34
import type { RdfObjectLoader, Resource } from 'rdf-object';
45
import { termToString } from 'rdf-string';
56
import type { Logger } from 'winston';
@@ -15,13 +16,15 @@ export class ConfigRegistry {
1516
private readonly logger: Logger;
1617
private readonly skipContextValidation: boolean;
1718
private readonly remoteContextLookups: boolean;
19+
private readonly contextParser?: ContextParser;
1820

1921
public constructor(options: IConfigLoaderRegistryOptions) {
2022
this.moduleState = options.moduleState;
2123
this.objectLoader = options.objectLoader;
2224
this.logger = options.logger;
2325
this.skipContextValidation = options.skipContextValidation;
2426
this.remoteContextLookups = options.remoteContextLookups;
27+
this.contextParser = options.contextParser;
2528
}
2629

2730
/**
@@ -38,6 +41,7 @@ export class ConfigRegistry {
3841
logger: this.logger,
3942
skipContextValidation: this.skipContextValidation,
4043
remoteContextLookups: this.remoteContextLookups,
44+
contextParser: this.contextParser,
4145
}));
4246
}
4347

@@ -62,6 +66,7 @@ export class ConfigRegistry {
6266
): Promise<void> {
6367
// Create ad-hoc resource
6468
const configResource = this.objectLoader.createCompactedResource({
69+
// eslint-disable-next-line ts/naming-convention
6570
'@id': configId,
6671
types: componentTypeIri,
6772
});
@@ -85,4 +90,8 @@ export interface IConfigLoaderRegistryOptions {
8590
logger: Logger;
8691
skipContextValidation: boolean;
8792
remoteContextLookups: boolean;
93+
/**
94+
* An optional shared, cache-bearing JSON-LD context parser reused across all config files.
95+
*/
96+
contextParser?: ContextParser;
8897
}

lib/rdf/RdfParser.ts

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,61 @@
1-
import { createReadStream } from 'fs';
2-
import type { Readable } from 'stream';
1+
import { createReadStream, promises as fs } from 'node:fs';
2+
import type { Readable } from 'node:stream';
33
import type * as RDF from '@rdfjs/types';
4+
import * as JsonLdContextParser from 'jsonld-context-parser';
5+
import { ContextParser } from 'jsonld-context-parser';
46
import type { ParseOptions } from 'rdf-parse';
5-
import rdfParser from 'rdf-parse';
7+
import { rdfParser } from 'rdf-parse';
68
import type { Logger } from 'winston';
79
import { PrefetchedDocumentLoader } from './PrefetchedDocumentLoader';
810
import { RdfStreamIncluder } from './RdfStreamIncluder';
9-
// Import syntax only works in Node > 12
10-
const fs = require('fs').promises;
1111

1212
/**
1313
* Parses a data stream to a triple stream.
1414
*/
1515
export class RdfParser {
16+
/**
17+
* Create a single, cache-bearing JSON-LD context parser that can be reused across all files
18+
* that are parsed during a component/config load.
19+
*
20+
* By default, Components.js constructs a fresh {@link PrefetchedDocumentLoader} and (indirectly,
21+
* via jsonld-streaming-parser) a fresh `ContextParser` for every `.jsonld` file. As a result the
22+
* well-known `@context`s (e.g. the shared `componentsjs` and `@solid/community-server` contexts)
23+
* are re-loaded and re-normalized once per file - during a Community Solid Server boot the
24+
* `componentsjs` context alone is normalized ~979 times.
25+
*
26+
* Passing the returned parser to every parse (see {@link RdfParserOptions#contextParser}) shares a
27+
* single {@link PrefetchedDocumentLoader}, a single raw-document cache, and a single normalized
28+
* {@link ContextCache} across all files, so the well-known contexts are loaded once and (once the
29+
* shared cache can be hit) normalized once instead of once per file.
30+
*
31+
* @param options Options describing the prefetched contexts and validation behaviour.
32+
*/
33+
public static createSharedContextParser(options: ISharedContextParserOptions): ContextParser {
34+
const documentLoader = new PrefetchedDocumentLoader({
35+
contexts: options.contexts ?? {},
36+
logger: options.logger,
37+
remoteContextLookups: options.remoteContextLookups,
38+
});
39+
const parserOptions: Record<string, any> = {
40+
documentLoader,
41+
skipValidation: options.skipContextValidation,
42+
};
43+
// Attach a shared normalized-context cache when the installed jsonld-context-parser exposes one
44+
// (feature-detected so this remains compatible with versions that predate the cache).
45+
if ((<any> JsonLdContextParser).ContextCache) {
46+
parserOptions.contextCache = new (<any> JsonLdContextParser).ContextCache();
47+
}
48+
return new ContextParser(parserOptions);
49+
}
50+
1651
/**
1752
* Parses the given stream into RDF quads.
1853
* @param textStream A text stream.
1954
* @param options Parsing options.
2055
*/
2156
public parse(textStream: NodeJS.ReadableStream, options: RdfParserOptions): RDF.Stream & Readable {
2257
// Parsing libraries don't work as expected if path contains backslashes
23-
options.path = options.path.replace(/\\+/gu, '/');
58+
options.path = options.path.replaceAll(/\\+/gu, '/');
2459

2560
if (!options.baseIRI) {
2661
// Try converting path to URL using defined import paths
@@ -44,10 +79,12 @@ export class RdfParser {
4479
}
4580

4681
// Set JSON-LD parser options
47-
(<any> options)['@comunica/actor-rdf-parse-jsonld:parserOptions'] = {
48-
// Override the JSON-LD document loader
82+
const jsonLdParserOptions: Record<string, any> = {
83+
// Override the JSON-LD document loader.
84+
// This is always provided so that a jsonld-streaming-parser that does not (yet) support an
85+
// injected context parser still uses the prefetched contexts and remote-lookup protection.
4986
documentLoader: new PrefetchedDocumentLoader({
50-
contexts: options.contexts || {},
87+
contexts: options.contexts ?? {},
5188
logger: options.logger,
5289
path: options.path,
5390
remoteContextLookups: options.remoteContextLookups,
@@ -57,6 +94,15 @@ export class RdfParser {
5794
// If JSON-LD context validation should be skipped
5895
skipContextValidation: options.skipContextValidation,
5996
};
97+
if (options.contextParser) {
98+
// Reuse one shared, cache-bearing context parser (which carries its own single prefetched
99+
// document loader) across all files, so the shared @contexts are only loaded/normalized once
100+
// instead of once per file. This is honoured by a jsonld-streaming-parser that supports the
101+
// `contextParser` option; on older versions the option is ignored and the per-file
102+
// `documentLoader` above is used, preserving the previous behaviour.
103+
jsonLdParserOptions.contextParser = options.contextParser;
104+
}
105+
(<any> options)['@comunica/actor-rdf-parse-jsonld:parserOptions'] = jsonLdParserOptions;
60106

61107
// Execute parsing
62108
const quadStream = rdfParser.parse(textStream, options);
@@ -131,4 +177,30 @@ export type RdfParserOptions = ParseOptions & {
131177
* If allowed, only a warning is emitted.
132178
*/
133179
remoteContextLookups?: boolean;
180+
/**
181+
* An optional shared JSON-LD context parser to reuse across all files.
182+
* When provided, this (cache-bearing) parser and its prefetched document loader are used for all
183+
* context resolution instead of constructing a new document loader (and context parser) per file.
184+
* Create one with {@link RdfParser#createSharedContextParser}.
185+
*/
186+
contextParser?: ContextParser;
134187
};
188+
189+
export interface ISharedContextParserOptions {
190+
/**
191+
* The cached JSON-LD contexts (id -> context document).
192+
*/
193+
contexts?: Record<string, any>;
194+
/**
195+
* An optional logger, used to warn on remote context lookups.
196+
*/
197+
logger?: Logger;
198+
/**
199+
* If remote context lookups are allowed.
200+
*/
201+
remoteContextLookups?: boolean;
202+
/**
203+
* If JSON-LD context validation should be skipped.
204+
*/
205+
skipContextValidation?: boolean;
206+
}

0 commit comments

Comments
 (0)