diff --git a/js/optify-config/package.json b/js/optify-config/package.json index c54e0282..7e9a8981 100644 --- a/js/optify-config/package.json +++ b/js/optify-config/package.json @@ -54,5 +54,8 @@ "universal": "napi universalize", "version": "napi version" }, - "packageManager": "yarn@4.9.1" + "packageManager": "yarn@4.9.1", + "dependencies": { + "lru-cache": "^11.0.0" + } } diff --git a/js/optify-config/src/cache-init-options.ts b/js/optify-config/src/cache-init-options.ts new file mode 100644 index 00000000..f1a8989f --- /dev/null +++ b/js/optify-config/src/cache-init-options.ts @@ -0,0 +1,16 @@ +/** + * Configuration options for cache initialization. + * Used when building providers/watchers to configure cache behavior. + */ +export class CacheInitOptions { + /** + * The maximum number of entries to keep in the cache. + * When the cache is full, the least recently used entry will be evicted. + * If not set, the cache size is unlimited. + */ + readonly maxSize?: number; + + constructor(maxSize?: number) { + this.maxSize = maxSize; + } +} diff --git a/js/optify-config/src/cache-options.ts b/js/optify-config/src/cache-options.ts new file mode 100644 index 00000000..00d0d1e9 --- /dev/null +++ b/js/optify-config/src/cache-options.ts @@ -0,0 +1,7 @@ +/** + * Options for enabling caching of deserialized objects returned by getOptions. + * Pass an instance of this class to getOptions to enable caching. + * Subsequent calls with the same key, feature names, schema, and preferences + * will return the same cached object without re-parsing. + */ +export class CacheOptions { } diff --git a/js/optify-config/src/caching.ts b/js/optify-config/src/caching.ts index 33caf46c..2127df49 100644 --- a/js/optify-config/src/caching.ts +++ b/js/optify-config/src/caching.ts @@ -1,31 +1,30 @@ // Caching utilities for getOptions +import { LRUCache } from 'lru-cache'; import * as nativeBinding from '../index'; +import { CacheInitOptions } from './cache-init-options'; +import { CacheOptions } from './cache-options'; import { TypeSchema } from './types'; -/** - * Options for enabling caching of deserialized objects returned by getOptions. - * Pass an instance of this class to getOptions to enable caching. - * Subsequent calls with the same key, feature names, schema, and preferences - * will return the same cached object without re-parsing. - */ -export class CacheOptions { } - // Private cache property names (using symbols for true privacy) export const FEATURES_WITH_METADATA_CACHE_KEY = Symbol('featuresWithMetadataCache'); export const FEATURES_WITH_METADATA_CACHE_TIME_KEY = Symbol('featuresWithMetadataCacheTime'); const OPTIONS_CACHE_KEY = Symbol('optionsCache'); export const CACHE_CREATION_TIME_KEY = Symbol('cacheCreationTime'); +const CACHE_INIT_OPTIONS_KEY = Symbol('cacheInitOptions'); const SCHEMA_IDS_KEY = Symbol('schemaIds'); const SCHEMA_ID_COUNTER_KEY = Symbol('schemaIdCounter'); +type OptionsCache = Map> | LRUCache>; + /** Instance with dynamic properties for caching. */ export interface CacheableInstance { _getOptions(key: string, featureNames: string[], preferences?: nativeBinding.GetOptionsPreferences | null): unknown; getFilteredFeatures(featureNames: string[], preferences: nativeBinding.GetOptionsPreferences): string[]; lastModified?(): number; [FEATURES_WITH_METADATA_CACHE_KEY]?: Record; - [OPTIONS_CACHE_KEY]?: Map; + [OPTIONS_CACHE_KEY]?: OptionsCache; + [CACHE_INIT_OPTIONS_KEY]?: CacheInitOptions | null; [SCHEMA_ID_COUNTER_KEY]?: number; [SCHEMA_IDS_KEY]?: WeakMap; } @@ -67,18 +66,39 @@ function createOptionsCacheKey( ]); } +function createOptionsCache(cacheInitOptions?: CacheInitOptions | null): OptionsCache { + if (cacheInitOptions?.maxSize !== undefined) { + return new LRUCache>({ max: cacheInitOptions.maxSize }); + } + return new Map>(); +} + /** * Resets all caches for the instance. * Used by OptionsWatcher when files are modified. */ export function resetCaches(instance: CacheableInstance): void { instance[FEATURES_WITH_METADATA_CACHE_KEY] = undefined; - instance[OPTIONS_CACHE_KEY] = new Map(); + instance[OPTIONS_CACHE_KEY] = createOptionsCache(instance[CACHE_INIT_OPTIONS_KEY]); +} + +/** + * Eagerly initializes the cache for the instance. + * Optional — if not called, the cache is lazily initialized on first `getOptions` call with `cacheOptions`. + * Call this to configure cache behavior (e.g., max size) before `getOptions`. + * @param instance The cacheable instance to initialize. + * @param cacheInitOptions Optional cache initialization options to configure cache behavior. + */ +export function initCache(instance: CacheableInstance, cacheInitOptions?: CacheInitOptions | null): OptionsCache { + instance[CACHE_INIT_OPTIONS_KEY] = cacheInitOptions; + return instance[OPTIONS_CACHE_KEY] = createOptionsCache(cacheInitOptions); } /** * Shared implementation of getOptions with optional caching support. * Used by both `OptionsProvider` and `OptionsWatcher`. + * Caching is enabled when `cacheOptions` is provided. The cache is lazily + * initialized on first use if `init` was not called. */ export function getOptionsWithCaching( instance: CacheableInstance, @@ -98,11 +118,8 @@ export function getOptionsWithCaching( const areConfigurableStringsEnabled = preferences?.areConfigurableStringsEnabled?.() ?? false; - let cache = instance[OPTIONS_CACHE_KEY]; - if (!cache) { - cache = new Map(); - instance[OPTIONS_CACHE_KEY] = cache; - } + const cache = instance[OPTIONS_CACHE_KEY] + ?? initCache(instance); const cacheKey = createOptionsCacheKey(instance, key, filteredFeatures, areConfigurableStringsEnabled, schema); const cachedResult = cache.get(cacheKey); @@ -118,7 +135,7 @@ export function getOptionsWithCaching( } const result = schema.parse(instance._getOptions(key, filteredFeatures, cacheMissPreferences)); - cache.set(cacheKey, result); + cache.set(cacheKey, result as NonNullable); return result; } diff --git a/js/optify-config/src/index.ts b/js/optify-config/src/index.ts index 1fcd20fc..58e65f53 100644 --- a/js/optify-config/src/index.ts +++ b/js/optify-config/src/index.ts @@ -3,12 +3,14 @@ // Import the generated native bindings. import * as nativeBinding from '../index'; +import { CacheInitOptions } from './cache-init-options'; +import { CacheOptions } from './cache-options'; import { CACHE_CREATION_TIME_KEY, - CacheOptions, FEATURES_WITH_METADATA_CACHE_KEY, FEATURES_WITH_METADATA_CACHE_TIME_KEY, getOptionsWithCaching, + initCache, resetCaches } from './caching'; import { TypeSchema } from './types'; @@ -27,7 +29,8 @@ export { export type OptionsProvider = nativeBinding.OptionsProvider; export type OptionsWatcher = nativeBinding.OptionsWatcher; -export { CacheOptions } from './caching'; +export { CacheInitOptions }; +export { CacheOptions }; export type { TypeSchema } from './types'; // Augment the native class interfaces to include our new method @@ -35,6 +38,14 @@ declare module '../index' { interface OptionsProvider { /** Returns a map of all the canonical feature names to their metadata. */ featuresWithMetadata(): Record; + /** + * Eagerly initializes the cache. Optional — the cache is lazily initialized on first + * `getOptions` call with `cacheOptions` if not called. Use this to configure cache behavior + * (e.g., max size) before `getOptions`. + * @param cacheInitOptions Optional cache initialization options to configure cache behavior. + * @returns `this` for chaining. + */ + init(cacheInitOptions?: CacheInitOptions | null): OptionsProvider; /** * Gets options for the specified key and feature names, validated against a schema. * @param cacheOptions Optional cache options to enable caching of the deserialized result. @@ -45,6 +56,14 @@ declare module '../index' { interface OptionsWatcher { /** Returns a map of all the canonical feature names to their metadata. */ featuresWithMetadata(): Record; + /** + * Eagerly initializes the cache. Optional — the cache is lazily initialized on first + * `getOptions` call with `cacheOptions` if not called. Use this to configure cache behavior + * (e.g., max size) before `getOptions`. + * @param cacheInitOptions Optional cache initialization options to configure cache behavior. + * @returns `this` for chaining. + */ + init(cacheInitOptions?: CacheInitOptions | null): OptionsWatcher; /** * Gets options for the specified key and feature names, validated against a schema. * @param cacheOptions Optional cache options to enable caching of the deserialized result. @@ -63,6 +82,10 @@ export const OptionsProvider = nativeBinding.OptionsProvider; return this[FEATURES_WITH_METADATA_CACHE_KEY] = this._featuresWithMetadata(); }; +(OptionsProvider.prototype as any).init = function (this: any, cacheInitOptions?: CacheInitOptions | null): any { + initCache(this, cacheInitOptions); + return this; +}; (OptionsProvider.prototype as any).getOptions = function (this: any, key: string, featureNames: string[], schema: any, preferences?: nativeBinding.GetOptionsPreferences | null, cacheOptions?: CacheOptions): any { return getOptionsWithCaching(this, key, featureNames, schema, preferences, cacheOptions); }; @@ -80,6 +103,11 @@ export const OptionsWatcher = nativeBinding.OptionsWatcher; this[FEATURES_WITH_METADATA_CACHE_TIME_KEY] = lastModifiedTime; return this[FEATURES_WITH_METADATA_CACHE_KEY] = this._featuresWithMetadata(); }; +(OptionsWatcher.prototype as any).init = function (this: any, cacheInitOptions?: CacheInitOptions | null): any { + initCache(this, cacheInitOptions); + this[CACHE_CREATION_TIME_KEY] = this.lastModified(); + return this; +}; (OptionsWatcher.prototype as any).getOptions = function (this: any, key: string, featureNames: string[], schema: any, preferences?: nativeBinding.GetOptionsPreferences | null, cacheOptions?: CacheOptions | null): any { // Check cache validity for watcher - reset if files have been modified if (cacheOptions) { diff --git a/js/optify-config/tests/cache.test.ts b/js/optify-config/tests/cache.test.ts index 0c735a5e..f0873eb7 100644 --- a/js/optify-config/tests/cache.test.ts +++ b/js/optify-config/tests/cache.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from '@jest/globals'; import path from 'path'; import { z } from 'zod'; -import { CacheOptions, GetOptionsPreferences, OptionsProvider, OptionsWatcher } from "../dist/index"; +import { CacheInitOptions, CacheOptions, GetOptionsPreferences, OptionsProvider, OptionsWatcher } from "../dist/index"; const DeeperObjectSchema = z.object({ wtv: z.number(), @@ -27,10 +27,10 @@ describe('getOptions caching', () => { const cacheOptions = new CacheOptions(); const providers = [{ name: "OptionsProvider", - provider: OptionsProvider.build(configsPath), + provider: OptionsProvider.build(configsPath).init(), }, { name: "OptionsWatcher", - provider: OptionsWatcher.build(configsPath), + provider: OptionsWatcher.build(configsPath).init(), }]; for (const { name, provider } of providers) { @@ -41,7 +41,6 @@ describe('getOptions caching', () => { expect(config1).toBe(config2); expect(config1.rootString).toBe('root string same'); - // Verify cached result equals non-cached result const configNonCached = provider.getOptions('myConfig', ['A'], MyConfigSchema); expect(config1).toEqual(configNonCached); }); @@ -108,3 +107,74 @@ describe('getOptions caching', () => { }); } }); + +describe('getOptions caching without init', () => { + const configsPath = path.join(__dirname, '../../../tests/test_suites/simple/configs'); + const cacheOptions = new CacheOptions(); + + for (const { name, provider } of [{ + name: "OptionsProvider", + provider: OptionsProvider.build(configsPath), + }, { + name: "OptionsWatcher", + provider: OptionsWatcher.build(configsPath), + }]) { + test(`${name} caches without calling init`, () => { + const config1 = provider.getOptions('myConfig', ['A'], MyConfigSchema, null, cacheOptions); + const config2 = provider.getOptions('myConfig', ['A'], MyConfigSchema, null, cacheOptions); + + expect(config1).toBe(config2); + + const configNonCached = provider.getOptions('myConfig', ['A'], MyConfigSchema); + expect(config1).toEqual(configNonCached); + expect(config1).not.toBe(configNonCached); + }); + } +}); + +describe('getOptions caching with maxSize', () => { + const configsPath = path.join(__dirname, '../../../tests/test_suites/simple/configs'); + const cacheOptions = new CacheOptions(); + + const builders = [{ + name: "OptionsProvider", + build: () => OptionsProvider.build(configsPath), + }, { + name: "OptionsWatcher", + build: () => OptionsWatcher.build(configsPath), + }]; + + for (const { name, build } of builders) { + // Each test gets a fresh provider to avoid sharing cache state with other tests + const makeProvider = (cacheInitOptions?: CacheInitOptions) => build().init(cacheInitOptions); + + test(`${name} unlimited cache when maxSize is not set`, () => { + const provider = makeProvider(); + const config = provider.getOptions('myConfig', ['A'], MyConfigSchema, null, cacheOptions); + const configAgain = provider.getOptions('myConfig', ['A'], MyConfigSchema, null, cacheOptions); + expect(config).toBe(configAgain); + }); + + test(`${name} evicts least recently used entry when maxSize is reached`, () => { + // maxSize=1 means only 1 entry fits; the second access evicts the first + const provider = makeProvider(new CacheInitOptions(1)); + + // Populate cache with ['A'] entry + const configA1 = provider.getOptions('myConfig', ['A'], MyConfigSchema, null, cacheOptions); + + // Access a different entry (different schema) to fill the cache and evict the first entry + const PartialSchema = z.object({ rootString: z.string() }); + provider.getOptions('myConfig', ['A'], PartialSchema, null, cacheOptions); + + // MyConfigSchema entry was evicted; re-fetching produces a new object + const configA2 = provider.getOptions('myConfig', ['A'], MyConfigSchema, null, cacheOptions); + expect(configA1).not.toBe(configA2); + // The content should still be equal + expect(configA1).toEqual(configA2); + + // Get the same thing and it should be the same cached object + const configA2Again = provider.getOptions('myConfig', ['A'], MyConfigSchema, null, cacheOptions); + expect(configA2).toBe(configA2Again); + }); + } +}); diff --git a/js/optify-config/yarn.lock b/js/optify-config/yarn.lock index ce22177e..51975e40 100644 --- a/js/optify-config/yarn.lock +++ b/js/optify-config/yarn.lock @@ -1697,6 +1697,7 @@ __metadata: "@types/mocha": "npm:^10.0.10" corepack: "npm:^0.32.0" jest: "npm:^29.7.0" + lru-cache: "npm:^11.0.0" tinybench: "npm:^5.1.0" ts-jest: "npm:^29.3.4" typescript: "npm:^5.8.3" @@ -3570,6 +3571,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^11.0.0": + version: 11.3.1 + resolution: "lru-cache@npm:11.3.1" + checksum: 10c0/4fb5cf792cf6fb40bee3f4a0a0903f74b7869bfeaf500a6194dffcd556d16819342f02a7d0bac13b55557ddf231970c58740ff2098ecffbf23c8a99569084acb + languageName: node + linkType: hard + "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" diff --git a/ruby/optify/lib/optify_ruby/cache_init_options.rb b/ruby/optify/lib/optify_ruby/cache_init_options.rb index af4b7aab..8e68e9fe 100644 --- a/ruby/optify/lib/optify_ruby/cache_init_options.rb +++ b/ruby/optify/lib/optify_ruby/cache_init_options.rb @@ -23,7 +23,7 @@ class CacheInitOptions # Initializes the cache options. # Defaults to a non-thread-safe unlimited size cache for backwards compatibility - # with how this library was originally configured with an unbounded hash as the case. + # with how this library was originally configured with an unbounded hash as the base. # @param mode A value from `CacheMode`. # #: ( diff --git a/rust/optify/.claude/settings.json b/rust/optify/.claude/settings.json deleted file mode 100644 index bb5aa715..00000000 --- a/rust/optify/.claude/settings.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Bash|Edit|MultiEdit|Update|Write", - "hooks": [ - { - "type": "command", - "command": "cargo fmt" - } - ] - }, - { - "matcher": "Bash|Edit|MultiEdit|Update|Write", - "hooks": [ - { - "type": "command", - "command": "cargo clippy --fix --allow-dirty --allow-staged" - } - ] - } - ] - } -} \ No newline at end of file