Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion js/optify-config/package.json
Comment thread
juharris marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
16 changes: 16 additions & 0 deletions js/optify-config/src/cache-init-options.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
7 changes: 7 additions & 0 deletions js/optify-config/src/cache-options.ts
Original file line number Diff line number Diff line change
@@ -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 { }
49 changes: 33 additions & 16 deletions js/optify-config/src/caching.ts
Original file line number Diff line number Diff line change
@@ -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<string, NonNullable<unknown>> | LRUCache<string, NonNullable<unknown>>;

/** 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<string, nativeBinding.OptionsMetadata>;
[OPTIONS_CACHE_KEY]?: Map<string, unknown>;
[OPTIONS_CACHE_KEY]?: OptionsCache;
[CACHE_INIT_OPTIONS_KEY]?: CacheInitOptions | null;
[SCHEMA_ID_COUNTER_KEY]?: number;
[SCHEMA_IDS_KEY]?: WeakMap<object, number>;
}
Expand Down Expand Up @@ -67,18 +66,39 @@ function createOptionsCacheKey(
]);
}

function createOptionsCache(cacheInitOptions?: CacheInitOptions | null): OptionsCache {
if (cacheInitOptions?.maxSize !== undefined) {
return new LRUCache<string, NonNullable<unknown>>({ max: cacheInitOptions.maxSize });
}
return new Map<string, NonNullable<unknown>>();
}

/**
* 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<T>(
instance: CacheableInstance,
Expand All @@ -98,11 +118,8 @@ export function getOptionsWithCaching<T>(

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);
Expand All @@ -118,7 +135,7 @@ export function getOptionsWithCaching<T>(
}

const result = schema.parse(instance._getOptions(key, filteredFeatures, cacheMissPreferences));
cache.set(cacheKey, result);
cache.set(cacheKey, result as NonNullable<unknown>);
return result;
}

Expand Down
32 changes: 30 additions & 2 deletions js/optify-config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,14 +29,23 @@ 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
declare module '../index' {
interface OptionsProvider {
/** Returns a map of all the canonical feature names to their metadata. */
featuresWithMetadata(): Record<string, OptionsMetadata>;
/**
* 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.
Expand All @@ -45,6 +56,14 @@ declare module '../index' {
interface OptionsWatcher {
/** Returns a map of all the canonical feature names to their metadata. */
featuresWithMetadata(): Record<string, OptionsMetadata>;
/**
* 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.
Expand All @@ -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);
};
Expand All @@ -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) {
Expand Down
78 changes: 74 additions & 4 deletions js/optify-config/tests/cache.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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) {
Expand All @@ -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);
});
Expand Down Expand Up @@ -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);
});
}
});
8 changes: 8 additions & 0 deletions js/optify-config/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion ruby/optify/lib/optify_ruby/cache_init_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
#
#: (
Expand Down
24 changes: 0 additions & 24 deletions rust/optify/.claude/settings.json

This file was deleted.

Loading