Skip to content
Closed
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
33 changes: 30 additions & 3 deletions packages/gcache-ts/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @rungalileo/gcache

TypeScript port of GCache. Milestone 2 ships explicit enabled contexts, stable key construction, local TTL caching, and optional Redis-backed distributed TTL caching with fail-open behavior.
TypeScript port of GCache. Milestone 3 ships explicit enabled contexts, stable key construction, local/Redis TTL caching, runtime config providers, and gradual rollout ramp controls with fail-open behavior.

## Install

Expand Down Expand Up @@ -58,6 +58,7 @@ local cache -> Redis cache -> fallback function
- Local misses try Redis and populate local on a Redis hit.
- Redis misses call the fallback and write both Redis and local.
- Redis read/write/delete/flush failures are logged and fail open; fallback results still return when fallback succeeds.
- Missing per-layer config disables that layer and falls through to the next layer/fallback.

You can also provide `createClient` for lazy client construction:

Expand All @@ -83,6 +84,29 @@ type RedisValueEnvelope = {

`payload` is produced by the cached function's serializer, or by `JsonSerializer` by default. Custom serializers can return either `string` or `Buffer`; Buffer payloads are base64 encoded in the envelope.

## Runtime config and ramp controls

Every cached function can provide a decorator-local `defaultConfig`; a `cacheConfigProvider` can override it at runtime. If the provider returns `null`, GCache falls back to the cached function's `defaultConfig`. If neither exists, or a layer's TTL/ramp is missing or disabled, only that layer is skipped.

```ts
import { CacheLayer, GCache, GCacheKeyConfig } from "@rungalileo/gcache";

const gcache = new GCache({
cacheConfigProvider: async (key) => {
if (key.useCase === "GetUser") {
return new GCacheKeyConfig({
ttlSec: { [CacheLayer.LOCAL]: 30, [CacheLayer.REMOTE]: 300 },
ramp: { [CacheLayer.LOCAL]: 100, [CacheLayer.REMOTE]: 25 },
});
}
return null; // use the cached function's defaultConfig
},
rampSampler: ({ key, layer }) => deterministicPercentFor(`${key.urn}:${layer}`),
});
```

`ramp` values are percentages from 0 to 100. `0` disables the layer, `100` enables it, and intermediate values use `rampSampler`; the default sampler is random. Provider errors fail open and execute the fallback function.

## Enabled context

The TypeScript port uses Node `AsyncLocalStorage` to mirror Python's `with gcache.enable():` safety model.
Expand Down Expand Up @@ -119,7 +143,7 @@ const searchPosts = gcache.cached({
});
```

## Milestone 2 scope
## Milestone 3 scope

Included:

Expand All @@ -132,10 +156,13 @@ Included:
- Duplicate and reserved use-case validation
- `delete` and `flushAll` across configured layers
- Fail-open behavior for key/config/cache errors
- Runtime config provider with fallback to cached-function `defaultConfig`
- Per-layer TTL and ramp controls
- Injectable ramp sampler for deterministic rollout tests
- Missing config disables only the relevant layer and falls through

Not included yet:

- Targeted invalidation and watermarks
- Runtime ramp controls
- Prometheus metrics
- Framework middleware helpers
12 changes: 12 additions & 0 deletions packages/gcache-ts/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,19 @@ export enum CacheLayer {
REMOTE = "remote",
}

export type Awaitable<T> = T | Promise<T>;
export type LayerConfig = Partial<Record<CacheLayer, number>>;

export interface CacheRampSample {
readonly key: GCacheKey;
readonly layer: CacheLayer;
readonly ramp: number;
}

export type CacheRampSampler = (sample: CacheRampSample) => Awaitable<number>;

export const randomRampSampler: CacheRampSampler = () => Math.random() * 100;

export class GCacheKeyConfig {
readonly ttlSec: LayerConfig;
readonly ramp: LayerConfig;
Expand Down Expand Up @@ -44,4 +55,5 @@ export interface GCacheConfig {
readonly logger?: Logger;
readonly localMaxSize?: number;
readonly redis?: RedisConfig;
readonly rampSampler?: CacheRampSampler;
}
11 changes: 8 additions & 3 deletions packages/gcache-ts/src/gcache.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GCacheConfig, type CacheConfigProvider, type Logger } from "./config.js";
import { GCacheConfig, randomRampSampler, type CacheConfigProvider, type CacheRampSampler, type Logger } from "./config.js";
import { GCacheContext } from "./context.js";
import { UseCaseIsAlreadyRegisteredError, UseCaseNameIsReservedError } from "./errors.js";
import { GCacheKey, normalizeArgs } from "./key.js";
Expand Down Expand Up @@ -30,14 +30,19 @@ export class GCache {
private readonly configProvider: CacheConfigProvider;
private readonly urnPrefix: string;
private readonly logger: Logger;
private readonly rampSampler: CacheRampSampler;
private readonly redisCache: RedisCache | null;

constructor(config: GCacheConfig = {}) {
this.configProvider = config.cacheConfigProvider ?? defaultConfigProvider;
this.urnPrefix = config.urnPrefix ?? "urn";
this.logger = config.logger ?? defaultLogger;
this.localCache = new LocalCache(this.configProvider, config.localMaxSize ?? DEFAULT_LOCAL_MAX_SIZE);
this.redisCache = config.redis === undefined ? null : new RedisCache({ configProvider: this.configProvider, redis: config.redis });
this.rampSampler = config.rampSampler ?? randomRampSampler;
this.localCache = new LocalCache(this.configProvider, this.rampSampler, config.localMaxSize ?? DEFAULT_LOCAL_MAX_SIZE);
this.redisCache =
config.redis === undefined
? null
: new RedisCache({ configProvider: this.configProvider, rampSampler: this.rampSampler, redis: config.redis });
}

enable<T>(fn: () => Awaitable<T>): Promise<T> {
Expand Down
4 changes: 2 additions & 2 deletions packages/gcache-ts/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { CacheLayer, GCacheKeyConfig } from "./config.js";
export type { CacheConfigProvider, GCacheConfig, LayerConfig, Logger } from "./config.js";
export { CacheLayer, GCacheKeyConfig, randomRampSampler } from "./config.js";
export type { CacheConfigProvider, CacheRampSample, CacheRampSampler, GCacheConfig, LayerConfig, Logger } from "./config.js";
export { GCacheContext } from "./context.js";
export {
GCacheError,
Expand Down
51 changes: 26 additions & 25 deletions packages/gcache-ts/src/internal/local-cache.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CacheLayer, type CacheConfigProvider, type GCacheKeyConfig } from "../config.js";
import { MissingKeyConfigError } from "../errors.js";
import { CacheLayer, type CacheConfigProvider, type CacheRampSampler } from "../config.js";
import type { GCacheKey } from "../key.js";
import { resolveLayerConfig } from "./runtime-config.js";

export type Fallback<T> = () => Promise<T>;

Expand All @@ -14,6 +14,7 @@ export class LocalCache {

constructor(
private readonly configProvider: CacheConfigProvider,
private readonly rampSampler: CacheRampSampler,
private readonly maxSize: number,
) {}

Expand All @@ -29,25 +30,34 @@ export class LocalCache {
}

async getIfPresent<T>(key: GCacheKey): Promise<T | undefined> {
const cache = await this.getUseCaseCache(key);
const layerConfig = await this.resolveLocalLayerConfig(key);
if (layerConfig === null) {
return undefined;
}

const cache = this.caches.get(key.useCase);
const now = Date.now();
const hit = cache.get(key.urn) as LocalEntry<T> | undefined;
const hit = cache?.get(key.urn) as LocalEntry<T> | undefined;

if (hit !== undefined && hit.expiresAtMs > now) {
return hit.value;
}

if (hit !== undefined) {
cache.delete(key.urn);
cache?.delete(key.urn);
}

return undefined;
}

async put<T>(key: GCacheKey, value: T): Promise<void> {
const cache = await this.getUseCaseCache(key);
const ttlSec = await this.resolveLocalTtl(key);
cache.set(key.urn, { expiresAtMs: Date.now() + ttlSec * 1000, value });
const layerConfig = await this.resolveLocalLayerConfig(key);
if (layerConfig === null) {
return;
}

const cache = this.getOrCreateUseCaseCache(key);
cache.set(key.urn, { expiresAtMs: Date.now() + layerConfig.ttlSec * 1000, value });
this.evictOldestIfNeeded(cache);
}

Expand All @@ -60,8 +70,7 @@ export class LocalCache {
this.caches.clear();
}

private async getUseCaseCache(key: GCacheKey): Promise<Map<string, LocalEntry<unknown>>> {
await this.resolveLocalTtl(key);
private getOrCreateUseCaseCache(key: GCacheKey): Map<string, LocalEntry<unknown>> {
let cache = this.caches.get(key.useCase);
if (cache === undefined) {
cache = new Map<string, LocalEntry<unknown>>();
Expand All @@ -70,21 +79,13 @@ export class LocalCache {
return cache;
}

private async resolveLocalTtl(key: GCacheKey): Promise<number> {
const config = await this.resolveConfig(key);
const ttlSec = config.ttlSec[CacheLayer.LOCAL];
if (ttlSec === undefined || ttlSec <= 0) {
throw new MissingKeyConfigError(key.useCase);
}
return ttlSec;
}

private async resolveConfig(key: GCacheKey): Promise<GCacheKeyConfig> {
const config = (await this.configProvider(key)) ?? key.defaultConfig;
if (config === null) {
throw new MissingKeyConfigError(key.useCase);
}
return config;
private async resolveLocalLayerConfig(key: GCacheKey) {
return await resolveLayerConfig({
configProvider: this.configProvider,
key,
layer: CacheLayer.LOCAL,
rampSampler: this.rampSampler,
});
}

private evictOldestIfNeeded(cache: Map<string, LocalEntry<unknown>>): void {
Expand Down
45 changes: 24 additions & 21 deletions packages/gcache-ts/src/internal/redis-cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CacheLayer, type CacheConfigProvider, type GCacheKeyConfig } from "../config.js";
import { MissingKeyConfigError } from "../errors.js";
import { CacheLayer, type CacheConfigProvider, type CacheRampSampler } from "../config.js";
import type { GCacheKey } from "../key.js";
import { JsonSerializer, type Serializer } from "../serializer.js";
import { resolveLayerConfig } from "./runtime-config.js";

export type Awaitable<T> = T | Promise<T>;
export type RedisStoredValue = string | Buffer;
Expand Down Expand Up @@ -35,6 +35,7 @@ export interface RedisValueEnvelope {

interface RedisCacheOptions {
readonly configProvider: CacheConfigProvider;
readonly rampSampler: CacheRampSampler;
readonly redis: RedisConfig;
}

Expand All @@ -43,13 +44,15 @@ const defaultSerializer = new JsonSerializer<unknown>();

export class RedisCache {
private readonly configProvider: CacheConfigProvider;
private readonly rampSampler: CacheRampSampler;
private readonly keyPrefix: string;
private readonly defaultSerializer: Serializer<unknown>;
private readonly createClient: RedisClientFactory | null;
private clientPromise: Promise<RedisCommandClient> | null;

constructor(options: RedisCacheOptions) {
this.configProvider = options.configProvider;
this.rampSampler = options.rampSampler;
this.keyPrefix = options.redis.keyPrefix ?? "";
this.defaultSerializer = options.redis.serializer ?? defaultSerializer;

Expand All @@ -62,7 +65,11 @@ export class RedisCache {
}

async get<T>(key: GCacheKey): Promise<T | undefined> {
await this.resolveRemoteTtl(key);
const layerConfig = await this.resolveRemoteLayerConfig(key);
if (layerConfig === null) {
return undefined;
}

const client = await this.resolveClient();
const raw = await client.get(this.redisKey(key));
if (raw === null) {
Expand All @@ -79,19 +86,23 @@ export class RedisCache {
}

async put<T>(key: GCacheKey, value: T): Promise<void> {
const ttlSec = await this.resolveRemoteTtl(key);
const layerConfig = await this.resolveRemoteLayerConfig(key);
if (layerConfig === null) {
return;
}

const client = await this.resolveClient();
const now = Date.now();
const payload = await this.serializerFor(key).dump(value);
const envelope: RedisValueEnvelope = {
version: ENVELOPE_VERSION,
createdAtMs: now,
expiresAtMs: now + ttlSec * 1000,
expiresAtMs: now + layerConfig.ttlSec * 1000,
encoding: Buffer.isBuffer(payload) ? "base64" : "utf8",
payload: Buffer.isBuffer(payload) ? payload.toString("base64") : payload,
};

await this.setWithTtl(client, this.redisKey(key), JSON.stringify(envelope), ttlSec);
await this.setWithTtl(client, this.redisKey(key), JSON.stringify(envelope), layerConfig.ttlSec);
}

async delete(key: GCacheKey): Promise<boolean> {
Expand Down Expand Up @@ -165,20 +176,12 @@ export class RedisCache {
throw new Error("Redis client does not implement setEx/setex/set");
}

private async resolveRemoteTtl(key: GCacheKey): Promise<number> {
const config = await this.resolveConfig(key);
const ttlSec = config.ttlSec[CacheLayer.REMOTE];
if (ttlSec === undefined || ttlSec <= 0) {
throw new MissingKeyConfigError(key.useCase);
}
return ttlSec;
}

private async resolveConfig(key: GCacheKey): Promise<GCacheKeyConfig> {
const config = (await this.configProvider(key)) ?? key.defaultConfig;
if (config === null) {
throw new MissingKeyConfigError(key.useCase);
}
return config;
private async resolveRemoteLayerConfig(key: GCacheKey) {
return await resolveLayerConfig({
configProvider: this.configProvider,
key,
layer: CacheLayer.REMOTE,
rampSampler: this.rampSampler,
});
}
}
51 changes: 51 additions & 0 deletions packages/gcache-ts/src/internal/runtime-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { CacheLayer, type CacheConfigProvider, type CacheRampSampler } from "../config.js";
import type { GCacheKey } from "../key.js";

export interface ResolvedLayerConfig {
readonly ttlSec: number;
readonly ramp: number;
}

interface ResolveLayerConfigOptions {
readonly configProvider: CacheConfigProvider;
readonly key: GCacheKey;
readonly layer: CacheLayer;
readonly rampSampler: CacheRampSampler;
}

export async function resolveLayerConfig(options: ResolveLayerConfigOptions): Promise<ResolvedLayerConfig | null> {
const config = (await options.configProvider(options.key)) ?? options.key.defaultConfig;
if (config === null) {
return null;
}

const ttlSec = config.ttlSec[options.layer];
if (ttlSec === undefined || ttlSec <= 0) {
return null;
}

const ramp = clampPercentage(config.ramp[options.layer] ?? 0);
if (ramp <= 0) {
return null;
}
if (ramp >= 100) {
return { ttlSec, ramp };
}

const sample = await options.rampSampler({ key: options.key, layer: options.layer, ramp });
if (!Number.isFinite(sample)) {
return null;
}

return clampPercentage(sample) < ramp ? { ttlSec, ramp } : null;
}

function clampPercentage(value: number): number {
if (value <= 0) {
return 0;
}
if (value >= 100) {
return 100;
}
return value;
}
Loading
Loading