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
66 changes: 59 additions & 7 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 1 intentionally ships a usable local-only library first: explicit enabled contexts, stable key construction, local TTL caching, and fail-open behavior.
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.

## Install

Expand Down Expand Up @@ -33,6 +33,56 @@ const user = await gcache.enable(async () => {
});
```

## Redis-backed TTL cache

Pass a small Redis command-surface client, or a lazy factory, to enable the read-through chain:

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

const gcache = new GCache({
redis: {
client: redisClient, // implements get, del, flushAll/flushall, and setEx/setex/set({ EX })
keyPrefix: "gcache:",
},
});
```

When caching is enabled, reads flow through:

```text
local cache -> Redis cache -> fallback function
```

- Local hits return immediately.
- 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.

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

```ts
const gcache = new GCache({
redis: {
createClient: async () => createRedisClient({ url: process.env.REDIS_URL }),
},
});
```

Redis payloads use a TypeScript-specific JSON envelope, not the Python pickle format:

```ts
type RedisValueEnvelope = {
version: 1;
createdAtMs: number;
expiresAtMs: number;
encoding: "utf8" | "base64";
payload: string;
};
```

`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.

## Enabled context

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

## Milestone 1 scope
## Milestone 2 scope

Included:

- Local TTL cache
- Explicit enabled context
- Explicit key builders
- Redis TTL cache
- Local → Redis → fallback read-through chain
- Lazy Redis client factory support
- Timestamped, versioned Redis envelope
- JSON and custom serializer support for Redis values
- Duplicate and reserved use-case validation
- `delete` and `flushAll`
- `delete` and `flushAll` across configured layers
- Fail-open behavior for key/config/cache errors

Not included yet:

- Redis
- Targeted invalidation and watermarks
- Runtime ramp controls
- Prometheus metrics
- Targeted invalidation
- Framework middleware helpers
2 changes: 1 addition & 1 deletion packages/gcache-ts/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@rungalileo/gcache",
"version": "0.1.0",
"description": "TypeScript port of GCache, starting with explicit-context local caching.",
"description": "TypeScript port of GCache with explicit-context local and Redis TTL caching.",
"license": "MIT",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
2 changes: 2 additions & 0 deletions packages/gcache-ts/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { GCacheKey } from "./key.js";
import type { RedisConfig } from "./internal/redis-cache.js";

export enum CacheLayer {
NOOP = "noop",
Expand Down Expand Up @@ -42,4 +43,5 @@ export interface GCacheConfig {
readonly urnPrefix?: string;
readonly logger?: Logger;
readonly localMaxSize?: number;
readonly redis?: RedisConfig;
}
76 changes: 70 additions & 6 deletions packages/gcache-ts/src/gcache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { UseCaseIsAlreadyRegisteredError, UseCaseNameIsReservedError } from "./e
import { GCacheKey, normalizeArgs } from "./key.js";
import type { Serializer } from "./serializer.js";
import { LocalCache } from "./internal/local-cache.js";
import { RedisCache } from "./internal/redis-cache.js";

type Awaitable<T> = T | Promise<T>;
type CacheableArgs = readonly unknown[];
Expand All @@ -29,12 +30,14 @@ export class GCache {
private readonly configProvider: CacheConfigProvider;
private readonly urnPrefix: string;
private readonly logger: Logger;
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 });
}

enable<T>(fn: () => Awaitable<T>): Promise<T> {
Expand Down Expand Up @@ -76,22 +79,83 @@ export class GCache {
return await fn(...args);
}

try {
return await this.localCache.get(key, async () => await fn(...args));
} catch (error) {
this.logger.error("Error getting value from local cache", error);
return await fn(...args);
if (this.redisCache === null) {
try {
return await this.localCache.get(key, async () => await fn(...args));
} catch (error) {
this.logger.error("Error getting value from local cache", error);
return await fn(...args);
}
}

return await this.getThroughRedisChain(key, async () => await fn(...args));
};
};
}

async delete(key: GCacheKey): Promise<boolean> {
return await this.localCache.delete(key);
const localDeleted = await this.localCache.delete(key);
if (this.redisCache === null) {
return localDeleted;
}

try {
return (await this.redisCache.delete(key)) || localDeleted;
} catch (error) {
this.logger.warn("Error deleting value from Redis cache", error);
return localDeleted;
}
}

async flushAll(): Promise<void> {
await this.localCache.flushAll();
if (this.redisCache === null) {
return;
}

try {
await this.redisCache.flushAll();
} catch (error) {
this.logger.warn("Error flushing Redis cache", error);
}
}

private async getThroughRedisChain<T>(key: GCacheKey, fallback: () => Promise<T>): Promise<T> {
try {
const localHit = await this.localCache.getIfPresent<T>(key);
if (localHit !== undefined) {
return localHit;
}
} catch (error) {
this.logger.warn("Error getting value from local cache", error);
}

try {
const redisHit = await this.redisCache?.get<T>(key);
if (redisHit !== undefined) {
await this.putLocalFailOpen(key, redisHit);
return redisHit;
}
} catch (error) {
this.logger.warn("Error getting value from Redis cache", error);
}

const value = await fallback();
try {
await this.redisCache?.put(key, value);
} catch (error) {
this.logger.warn("Error putting value in Redis cache", error);
}
await this.putLocalFailOpen(key, value);
return value;
}

private async putLocalFailOpen<T>(key: GCacheKey, value: T): Promise<void> {
try {
await this.localCache.put(key, value);
} catch (error) {
this.logger.warn("Error putting value in local cache", error);
}
}

private registerUseCase(useCase: string): void {
Expand Down
7 changes: 7 additions & 0 deletions packages/gcache-ts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,12 @@ export { GCache } from "./gcache.js";
export type { CachedOptions } from "./gcache.js";
export { GCacheKey, normalizeArgs } from "./key.js";
export type { GCacheKeyInit } from "./key.js";
export type {
RedisCommandClient,
RedisConfig,
RedisClientFactory,
RedisStoredValue,
RedisValueEnvelope,
} from "./internal/redis-cache.js";
export { JsonSerializer } from "./serializer.js";
export type { Serializer } from "./serializer.js";
15 changes: 12 additions & 3 deletions packages/gcache-ts/src/internal/local-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ export class LocalCache {
) {}

async get<T>(key: GCacheKey, fallback: Fallback<T>): Promise<T> {
const hit = await this.getIfPresent<T>(key);
if (hit !== undefined) {
return hit;
}

const value = await fallback();
await this.put(key, value);
return value;
}

async getIfPresent<T>(key: GCacheKey): Promise<T | undefined> {
const cache = await this.getUseCaseCache(key);
const now = Date.now();
const hit = cache.get(key.urn) as LocalEntry<T> | undefined;
Expand All @@ -30,9 +41,7 @@ export class LocalCache {
cache.delete(key.urn);
}

const value = await fallback();
await this.put(key, value);
return value;
return undefined;
}

async put<T>(key: GCacheKey, value: T): Promise<void> {
Expand Down
Loading
Loading