Skip to content

feat: Add an opt-in normalized-context cache - #85

Draft
jeswr wants to merge 1 commit into
rubensworks:masterfrom
jeswr:perf/context-cache-refresh
Draft

feat: Add an opt-in normalized-context cache#85
jeswr wants to merge 1 commit into
rubensworks:masterfrom
jeswr:perf/context-cache-refresh

Conversation

@jeswr

@jeswr jeswr commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

🚧 DRAFT — opened by @jeswr's AI agent on his behalf.
This refreshes @jeswr's stale PR #70 onto current master. Supersedes #70.
@jeswr will review this, mark it ready for review, and tag the maintainers when ready.
cc @jeswr

What this does

Adds an opt-in cache of the normalized context to ContextParser, so that a context which has already been parsed isn't normalized again. Caching is off by default — passing no contextCache preserves current behaviour exactly.

import { ContextParser, ContextCache } from 'jsonld-context-parser';

// One cache, shared across as many parsers as you like:
const contextCache = new ContextCache();            // bounded LRU (default max 512)
const parser = new ContextParser({ contextCache });

New public surface:

  • IContextCache — interface (hash / get / set).
  • ContextCache — a bounded, LRU-backed implementation.
  • IContextParserOptions.contextCache?: IContextCache.

Internally, parse becomes a thin caching wrapper and the normalization logic moves to a private _parse. All recursive sub-context parsing is routed through _parse (bypassing the cache) so that only fully post-processed top-level results are ever cached — this keeps cached entries safe from later mutation (e.g. base-IRI application on an outer parse).

Why — profiling evidence

Profiling a Community Solid Server boot (bin/server.js -c config/default.json, componentsjs 5.5.1, jsonld-context-parser 2.3.2):

Library % of active-CPU self-time
jsonld-context-parser 52.6 % (9.9 s)
jsonld-streaming-parser 8.2 %
componentsjs 3.6 %

ContextParser.parse alone is 36.7 % of active CPU. Runtime instrumentation of that exact boot showed the same @contexts being fully re-normalized over and over, because a fresh ContextParser (with an empty cache) is created per .jsonld file:

context load()ed during one boot
componentsjs ^5.0.0 979×
@solid/community-server ^7.0.0 594×
@comunica/core ^2.0.0 191×

A single ContextCache shared across those per-file parsers means each distinct context is normalized once and reused everywhere.

Benchmark (npm run perf)

Parsing the Components.js ^5.0.0 context (90 terms — one of the contexts re-normalized ~979× above), 1000×, with vs. without the cache. Indicative only — this box is shared with a live server:

scenario throughput per parse
no cache (re-normalized every parse) ~2.4k–3.8k ops/sec 0.26–0.41 ms
shared cache (single parser) ~24k–37k ops/sec 0.027–0.041 ms
shared cache across fresh parsers (CSS-boot shape) ~25k–35k ops/sec 0.029–0.040 ms

≈ 8–12× faster on a cache hit; the benchmark asserts the cached result is identical to the uncached one before timing.

Correctness — what changed vs. #70

#70's cache key had a latent bug: two parses that differ only in a (non-empty) parentContext hashed to the same key, so the second would get the first's (wrong) cached result. Verified against #70's exact hashing code:

PR #70 hash  -> same key for different parent contexts? true   (BUG: collapses distinct contexts)
this PR hash -> same key for different parent contexts? false  (fixed)

This PR derives the key from the full content of the context, its parent context, and the remaining options, so:

  • genuinely different contexts (or the same context under a different parent/options) are never collapsed;
  • keying on content (not object identity) means a context mutated between calls yields a different key — a stale result is never returned;
  • an empty parent context is treated as no parent context (they are behaviourally identical);
  • the cache is a bounded LRU, so long-running processes that see unboundedly many distinct contexts don't leak memory.

Because the returned normalized context may be shared across cache hits, the contract (documented on parse) is that callers must not mutate it when a cache is in use.

Tests & checks

  • yarn build, yarn lint (tslint, 0 warnings), yarn test, and npx webpack all green.
  • 501 tests pass (19 added), coverage unchanged (ContextCache.ts 100%). New tests cover: cache-hit identity, cross-parser reuse via a shared cache, the parent-context non-collapse (the feat: context cache #70 fix), empty-parent equivalence, LRU eviction, cached-rejection consistency, and the ContextCache.hash/get/set unit surface.

Note on the diff

.github/workflows/ci.yml and codeql-analysis.yml appear here only because @jeswr's fork is behind upstream on those files, and the agent's token lacks the workflow scope needed to push upstream's versions. They are pinned to the fork base in an isolated commit (chore: Pin workflow files…) and are not part of this change — they'll disappear once @jeswr syncs the fork and drops that commit.


Proposed changelog: Add an optional cache of normalized contexts (opt-in via the contextCache option / ContextCache).

Parsing a JSON-LD context is dominated by normalization (term expansion,
IRI validation, container hashing, keyword-redefinition checks). When the
same context is parsed many times this work is fully repeated every time.
Profiling a Community Solid Server boot showed over half of all active CPU
inside jsonld-context-parser, with the Components.js ^5.0.0 context alone
re-normalized ~979 times (once per referencing document, each with a
distinct per-document base IRI).

This adds an optional `contextCache` on the ContextParser that memoizes the
normalized result of `parse`. A single cache instance can be shared across
multiple ContextParser instances so a context is normalized once and reused
everywhere. Caching is disabled by default; passing no `contextCache`
preserves the previous behaviour exactly.

- `IContextCache` interface (hash/get/set) and a bounded LRU-backed
  `ContextCache` implementation, keyed on the full content of the context,
  its parent context, and the remaining options.
- Top-level object contexts are memoized directly (their key legitimately
  captures the base IRI). Loaded external (URL/array) contexts are instead
  memoized at a base-independent boundary (`parseExternalCached`): their
  normalization is computed once with the per-document base replaced by a
  fixed, unguessable sentinel, and the real base is substituted back on each
  retrieval by a copy-on-write walk (`rebaseSentinel`/`replaceSentinel`) that
  shares base-independent subtrees, so a cached entry is never mutated. The
  per-call base is re-applied on a shallow clone (`applyBaseEntryCloned`).
- Correctness is guaranteed: the real (base-carrying) parse is always
  authoritative, and a sentinel result is cached only after it is verified
  once, on the miss, to re-base byte-for-byte to the real parse (via
  `Util.deepEqual`). A context whose normalization genuinely depends on the
  base (relative @vocab, @type-to-@base expansion) simply fails that check
  and is not shared -- output stays byte-identical for every context.

On the CSS boot workload the external-context hit rate goes from 0% to
~99.8% and the parse is ~1.75x faster end-to-end, with identical output.

Supersedes rubensworks#70.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant