IMPORTANT: The experimental APIs documented below are not currently accessible in the published package due to missing package.json exports configuration. While the types exist in the codebase, they cannot be imported as documented. Users cannot currently use these APIs.
Experimental APIs for advanced feature flag caching and distributed coordination. These APIs are designed for high-scale production environments where multiple server instances need to share feature flag definitions efficiently.
Note: This documentation is kept for reference but the APIs described are not usable in version 5.13.2.
Provider interface for implementing custom feature flag caching strategies. Use this to control when flag definitions are fetched and how they're cached across multiple workers.
/**
* @experimental This API is experimental and may change in minor versions.
*
* Provider interface for caching feature flag definitions.
*
* Implementations control when flag definitions are fetched and how they're
* cached (Redis, database, filesystem, etc.).
*
* All methods may throw errors - the poller catches and logs them gracefully,
* ensuring cache provider errors never break flag evaluation.
*/
interface FlagDefinitionCacheProvider {
/**
* Retrieve cached flag definitions.
*
* Called when the poller is refreshing in-memory flag definitions.
* If this returns undefined (or throws), the poller fetches fresh data
* from PostHog API if no definitions are in memory.
*
* @returns cached definitions if available, undefined if cache is empty
* @throws if an error occurs while accessing the cache (error will be logged)
*/
getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> | FlagDefinitionCacheData | undefined;
/**
* Determines whether this instance should fetch new flag definitions.
*
* Use this to implement distributed coordination (e.g., via distributed locks)
* to ensure only one instance fetches at a time in a multi-worker setup.
*
* @returns true if this instance should fetch, false to skip and read cache
* @throws if coordination backend is unavailable (error will be logged, fetch continues)
*/
shouldFetchFlagDefinitions(): Promise<boolean> | boolean;
/**
* Called after successfully receiving new flag definitions from PostHog.
*
* Store the definitions in your cache backend here. Called only after
* a successful API response with valid flag data.
*
* @param data - The complete flag definition data from PostHog
* @throws if storage backend is unavailable (error will be logged)
*/
onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> | void;
/**
* Called when the PostHog client shuts down.
*
* Release any held locks, close connections, or clean up resources here.
*
* @returns Promise that resolves when cleanup is complete, or void for sync cleanup
*/
shutdown(): Promise<void> | void;
}Complete set of feature flag data needed for local evaluation.
/**
* Represents the complete set of feature flag data needed for local evaluation.
*
* This includes flag definitions, group type mappings, and cohort property groups.
*/
interface FlagDefinitionCacheData {
/** Array of feature flag definitions */
flags: PostHogFeatureFlag[];
/** Mapping of group type index to group name */
groupTypeMapping: Record<string, string>;
/** Cohort property groups for local evaluation */
cohorts: Record<string, PropertyGroup>;
}Import experimental APIs from the dedicated experimental module:
import {
FlagDefinitionCacheProvider,
FlagDefinitionCacheData
} from 'posthog-node/experimental';Complete example of a Redis-based cache provider with distributed locking:
import { PostHog } from 'posthog-node';
import {
FlagDefinitionCacheProvider,
FlagDefinitionCacheData
} from 'posthog-node/experimental';
import Redis from 'ioredis';
class RedisFlagCache implements FlagDefinitionCacheProvider {
private redis: Redis;
private teamKey: string;
private lockTTL: number = 60; // Lock expires after 60 seconds
private cacheTTL: number = 300; // Cache expires after 5 minutes
constructor(redis: Redis, teamKey: string) {
this.redis = redis;
this.teamKey = teamKey;
}
async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
try {
const cached = await this.redis.get(`posthog:flags:${this.teamKey}`);
if (cached) {
console.log('Using cached flag definitions');
return JSON.parse(cached);
}
console.log('No cached flag definitions found');
return undefined;
} catch (error) {
console.error('Error reading from cache:', error);
return undefined;
}
}
async shouldFetchFlagDefinitions(): Promise<boolean> {
try {
// Try to acquire distributed lock using SET NX (set if not exists)
const lockKey = `posthog:flags:${this.teamKey}:lock`;
const acquired = await this.redis.set(
lockKey,
'1',
'EX', this.lockTTL,
'NX'
);
if (acquired === 'OK') {
console.log('Acquired lock, will fetch flag definitions');
return true;
} else {
console.log('Another worker has the lock, will use cache');
return false;
}
} catch (error) {
console.error('Error acquiring lock:', error);
// If we can't acquire lock, fetch anyway to be safe
return true;
}
}
async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
try {
// Store flag definitions in cache
await this.redis.set(
`posthog:flags:${this.teamKey}`,
JSON.stringify(data),
'EX',
this.cacheTTL
);
// Release the lock
await this.redis.del(`posthog:flags:${this.teamKey}:lock`);
console.log('Cached flag definitions and released lock');
} catch (error) {
console.error('Error caching flag definitions:', error);
// Try to release lock even if caching failed
try {
await this.redis.del(`posthog:flags:${this.teamKey}:lock`);
} catch (lockError) {
console.error('Error releasing lock:', lockError);
}
}
}
async shutdown(): Promise<void> {
try {
// Release any locks we might hold
await this.redis.del(`posthog:flags:${this.teamKey}:lock`);
console.log('Released locks on shutdown');
} catch (error) {
console.error('Error during shutdown:', error);
}
}
}
// Usage
const redis = new Redis({
host: 'localhost',
port: 6379
});
const cache = new RedisFlagCache(redis, 'your-team-key');
const client = new PostHog('phc_your_api_key', {
host: 'https://app.posthog.com',
flagDefinitionCacheProvider: cache
});
// When done
await client.shutdown();
await redis.quit();Example using a SQL database for caching flag definitions:
import { PostHog } from 'posthog-node';
import {
FlagDefinitionCacheProvider,
FlagDefinitionCacheData
} from 'posthog-node/experimental';
import { Pool } from 'pg';
class PostgresFlagCache implements FlagDefinitionCacheProvider {
private pool: Pool;
private teamKey: string;
private lockTTL: number = 60000; // 60 seconds in milliseconds
private cacheTTL: number = 300000; // 5 minutes in milliseconds
constructor(pool: Pool, teamKey: string) {
this.pool = pool;
this.teamKey = teamKey;
}
async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
const client = await this.pool.connect();
try {
const result = await client.query(
`SELECT data, updated_at
FROM posthog_flag_cache
WHERE team_key = $1
AND updated_at > NOW() - INTERVAL '${this.cacheTTL} milliseconds'`,
[this.teamKey]
);
if (result.rows.length > 0) {
console.log('Using cached flag definitions from database');
return result.rows[0].data;
}
console.log('No valid cached flag definitions found');
return undefined;
} catch (error) {
console.error('Error reading from database cache:', error);
return undefined;
} finally {
client.release();
}
}
async shouldFetchFlagDefinitions(): Promise<boolean> {
const client = await this.pool.connect();
try {
// Use PostgreSQL advisory lock for distributed coordination
const result = await client.query(
`SELECT pg_try_advisory_lock($1) as acquired`,
[this.getLockId()]
);
const acquired = result.rows[0].acquired;
if (acquired) {
console.log('Acquired database lock, will fetch flag definitions');
return true;
} else {
console.log('Another worker has the lock, will use cache');
return false;
}
} catch (error) {
console.error('Error acquiring database lock:', error);
return true;
} finally {
client.release();
}
}
async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
const client = await this.pool.connect();
try {
// Upsert flag definitions
await client.query(
`INSERT INTO posthog_flag_cache (team_key, data, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (team_key)
DO UPDATE SET data = $2, updated_at = NOW()`,
[this.teamKey, data]
);
// Release advisory lock
await client.query(
`SELECT pg_advisory_unlock($1)`,
[this.getLockId()]
);
console.log('Cached flag definitions in database and released lock');
} catch (error) {
console.error('Error caching flag definitions:', error);
// Try to release lock even if caching failed
try {
await client.query(
`SELECT pg_advisory_unlock($1)`,
[this.getLockId()]
);
} catch (lockError) {
console.error('Error releasing lock:', lockError);
}
} finally {
client.release();
}
}
async shutdown(): Promise<void> {
const client = await this.pool.connect();
try {
// Release any advisory locks
await client.query(
`SELECT pg_advisory_unlock_all()`
);
console.log('Released all locks on shutdown');
} catch (error) {
console.error('Error during shutdown:', error);
} finally {
client.release();
}
}
private getLockId(): number {
// Convert team key to a numeric lock ID
let hash = 0;
for (let i = 0; i < this.teamKey.length; i++) {
hash = ((hash << 5) - hash) + this.teamKey.charCodeAt(i);
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash);
}
}
// Database schema
const schema = `
CREATE TABLE IF NOT EXISTS posthog_flag_cache (
team_key VARCHAR(255) PRIMARY KEY,
data JSONB NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_posthog_flag_cache_updated_at
ON posthog_flag_cache(updated_at);
`;
// Usage
const pool = new Pool({
host: 'localhost',
port: 5432,
database: 'myapp',
user: 'postgres',
password: 'password'
});
const cache = new PostgresFlagCache(pool, 'your-team-key');
const client = new PostHog('phc_your_api_key', {
host: 'https://app.posthog.com',
flagDefinitionCacheProvider: cache
});
// When done
await client.shutdown();
await pool.end();Lightweight implementation using in-memory cache with file system backup:
import { PostHog } from 'posthog-node';
import {
FlagDefinitionCacheProvider,
FlagDefinitionCacheData
} from 'posthog-node/experimental';
import fs from 'fs/promises';
import path from 'path';
class FileFlagCache implements FlagDefinitionCacheProvider {
private teamKey: string;
private cacheDir: string;
private cachePath: string;
private lockPath: string;
private lockTTL: number = 60000; // 60 seconds
private cacheTTL: number = 300000; // 5 minutes
constructor(teamKey: string, cacheDir: string = '/tmp/posthog-cache') {
this.teamKey = teamKey;
this.cacheDir = cacheDir;
this.cachePath = path.join(cacheDir, `flags-${teamKey}.json`);
this.lockPath = path.join(cacheDir, `flags-${teamKey}.lock`);
}
async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
try {
const stats = await fs.stat(this.cachePath);
const age = Date.now() - stats.mtimeMs;
if (age < this.cacheTTL) {
const content = await fs.readFile(this.cachePath, 'utf-8');
console.log('Using cached flag definitions from file');
return JSON.parse(content);
} else {
console.log('Cached file is too old');
return undefined;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Error reading cache file:', error);
}
return undefined;
}
}
async shouldFetchFlagDefinitions(): Promise<boolean> {
try {
// Ensure cache directory exists
await fs.mkdir(this.cacheDir, { recursive: true });
// Check if lock file exists and is recent
try {
const stats = await fs.stat(this.lockPath);
const age = Date.now() - stats.mtimeMs;
if (age < this.lockTTL) {
console.log('Lock file exists and is recent, another worker is fetching');
return false;
} else {
console.log('Lock file is stale, removing');
await fs.unlink(this.lockPath);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Error checking lock file:', error);
}
}
// Try to create lock file
try {
await fs.writeFile(this.lockPath, Date.now().toString(), { flag: 'wx' });
console.log('Acquired lock file, will fetch flag definitions');
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
console.log('Another worker created lock file first');
return false;
}
throw error;
}
} catch (error) {
console.error('Error acquiring lock:', error);
return true;
}
}
async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
try {
// Ensure cache directory exists
await fs.mkdir(this.cacheDir, { recursive: true });
// Write cache file
await fs.writeFile(this.cachePath, JSON.stringify(data), 'utf-8');
// Remove lock file
try {
await fs.unlink(this.lockPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Error removing lock file:', error);
}
}
console.log('Cached flag definitions to file and released lock');
} catch (error) {
console.error('Error caching flag definitions:', error);
}
}
async shutdown(): Promise<void> {
try {
// Remove lock file if it exists
await fs.unlink(this.lockPath);
console.log('Removed lock file on shutdown');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Error during shutdown:', error);
}
}
}
}
// Usage
const cache = new FileFlagCache('your-team-key', '/tmp/posthog-cache');
const client = new PostHog('phc_your_api_key', {
host: 'https://app.posthog.com',
flagDefinitionCacheProvider: cache
});
// When done
await client.shutdown();One worker fetches, others wait and use cache:
class LeaderElectionCache implements FlagDefinitionCacheProvider {
private cache: Map<string, any> = new Map();
private isLeader: boolean = false;
async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
return this.cache.get('flags');
}
async shouldFetchFlagDefinitions(): Promise<boolean> {
// Only leader fetches
return this.isLeader;
}
async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
this.cache.set('flags', data);
// Broadcast to other workers via pub/sub
await this.broadcastUpdate(data);
}
async shutdown(): Promise<void> {
this.isLeader = false;
}
private async broadcastUpdate(data: FlagDefinitionCacheData): Promise<void> {
// Implement using Redis pub/sub, ZeroMQ, etc.
}
}Workers take turns fetching based on time slots:
class TimeBasedCache implements FlagDefinitionCacheProvider {
private workerId: number;
private totalWorkers: number;
private intervalSeconds: number;
constructor(workerId: number, totalWorkers: number, intervalSeconds: number = 30) {
this.workerId = workerId;
this.totalWorkers = totalWorkers;
this.intervalSeconds = intervalSeconds;
}
async shouldFetchFlagDefinitions(): Promise<boolean> {
// Each worker gets a time slot
const currentSlot = Math.floor(Date.now() / 1000 / this.intervalSeconds) % this.totalWorkers;
return currentSlot === this.workerId;
}
// ... other methods
}Workers randomly decide to fetch with exponential backoff:
class RandomBackoffCache implements FlagDefinitionCacheProvider {
private lastFetchAttempt: number = 0;
private backoffMs: number = 1000;
async shouldFetchFlagDefinitions(): Promise<boolean> {
const now = Date.now();
const timeSinceLastAttempt = now - this.lastFetchAttempt;
if (timeSinceLastAttempt < this.backoffMs) {
return false;
}
// Random chance to fetch (10%)
const shouldFetch = Math.random() < 0.1;
if (shouldFetch) {
this.lastFetchAttempt = now;
}
return shouldFetch;
}
async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
// Reset backoff on success
this.backoffMs = 1000;
// ... store data
}
// ... other methods
}import { PostHog } from 'posthog-node';
import {
FlagDefinitionCacheProvider,
FlagDefinitionCacheData
} from 'posthog-node/experimental';
import Redis from 'ioredis';
// Use Redis for coordination across pods
const redis = new Redis(process.env.REDIS_URL);
const cache = new RedisFlagCache(
redis,
process.env.POSTHOG_TEAM_KEY || 'default'
);
const client = new PostHog(process.env.POSTHOG_API_KEY!, {
host: process.env.POSTHOG_HOST,
flagDefinitionCacheProvider: cache
});
// Handle graceful shutdown
process.on('SIGTERM', async () => {
console.log('Received SIGTERM, shutting down gracefully');
await client.shutdown();
await redis.quit();
process.exit(0);
});import { PostHog } from 'posthog-node';
import {
FlagDefinitionCacheProvider,
FlagDefinitionCacheData
} from 'posthog-node/experimental';
import { DynamoDB } from 'aws-sdk';
class DynamoDBFlagCache implements FlagDefinitionCacheProvider {
private dynamodb: DynamoDB.DocumentClient;
private tableName: string;
private teamKey: string;
constructor(tableName: string, teamKey: string) {
this.dynamodb = new DynamoDB.DocumentClient();
this.tableName = tableName;
this.teamKey = teamKey;
}
async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
try {
const result = await this.dynamodb.get({
TableName: this.tableName,
Key: { teamKey: this.teamKey }
}).promise();
if (result.Item && result.Item.data) {
const age = Date.now() - result.Item.updatedAt;
if (age < 300000) { // 5 minutes
return result.Item.data;
}
}
return undefined;
} catch (error) {
console.error('Error reading from DynamoDB:', error);
return undefined;
}
}
async shouldFetchFlagDefinitions(): Promise<boolean> {
// In Lambda, always let first invocation fetch
// Subsequent invocations will use cache
try {
const result = await this.dynamodb.get({
TableName: this.tableName,
Key: { teamKey: this.teamKey }
}).promise();
return !result.Item || (Date.now() - result.Item.updatedAt > 60000);
} catch (error) {
return true;
}
}
async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
try {
await this.dynamodb.put({
TableName: this.tableName,
Item: {
teamKey: this.teamKey,
data: data,
updatedAt: Date.now()
}
}).promise();
} catch (error) {
console.error('Error writing to DynamoDB:', error);
}
}
async shutdown(): Promise<void> {
// No cleanup needed for DynamoDB
}
}
// Lambda handler
let client: PostHog;
export const handler = async (event: any) => {
if (!client) {
const cache = new DynamoDBFlagCache(
process.env.DYNAMODB_TABLE!,
process.env.POSTHOG_TEAM_KEY!
);
client = new PostHog(process.env.POSTHOG_API_KEY!, {
flagDefinitionCacheProvider: cache
});
}
// Use feature flags
const isEnabled = await client.isFeatureEnabled('my-flag', event.userId);
return {
statusCode: 200,
body: JSON.stringify({ enabled: isEnabled })
};
};import { PostHog } from 'posthog-node';
import {
FlagDefinitionCacheProvider,
FlagDefinitionCacheData
} from 'posthog-node/experimental';
import { Worker, isMainThread, parentPort, workerData } from 'worker_threads';
class SharedMemoryCache implements FlagDefinitionCacheProvider {
private sharedData: SharedArrayBuffer;
private lock: Int32Array;
constructor(sharedBuffer: SharedArrayBuffer) {
this.sharedData = sharedBuffer;
this.lock = new Int32Array(sharedBuffer, 0, 1);
}
async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
// Read from shared memory
const view = new Uint8Array(this.sharedData, 4);
const data = Buffer.from(view).toString('utf-8');
if (data) {
return JSON.parse(data);
}
return undefined;
}
async shouldFetchFlagDefinitions(): Promise<boolean> {
// Try to acquire lock using atomic operations
const acquired = Atomics.compareExchange(this.lock, 0, 0, 1) === 0;
return acquired;
}
async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
// Write to shared memory
const json = JSON.stringify(data);
const buffer = Buffer.from(json, 'utf-8');
const view = new Uint8Array(this.sharedData, 4, buffer.length);
view.set(buffer);
// Release lock
Atomics.store(this.lock, 0, 0);
}
async shutdown(): Promise<void> {
// Release lock if held
Atomics.store(this.lock, 0, 0);
}
}
if (isMainThread) {
// Main thread creates shared buffer
const sharedBuffer = new SharedArrayBuffer(1024 * 1024); // 1MB
for (let i = 0; i < 4; i++) {
new Worker(__filename, {
workerData: { sharedBuffer }
});
}
} else {
// Worker thread uses shared cache
const cache = new SharedMemoryCache(workerData.sharedBuffer);
const client = new PostHog(process.env.POSTHOG_API_KEY!, {
flagDefinitionCacheProvider: cache
});
}Cache providers must never throw uncaught errors:
// Good: Handle all errors
async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
try {
const data = await this.redis.get('flags');
return data ? JSON.parse(data) : undefined;
} catch (error) {
console.error('Cache error:', error);
return undefined; // Graceful fallback
}
}
// Bad: Let errors propagate
async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
const data = await this.redis.get('flags'); // May throw
return JSON.parse(data); // May throw
}Balance freshness with API load:
// Good: Reasonable TTLs
const LOCK_TTL = 60; // 1 minute - lock expires if worker crashes
const CACHE_TTL = 300; // 5 minutes - fresh enough for most use cases
// Bad: Too short (high API load)
const LOCK_TTL = 5; // 5 seconds - too much lock contention
const CACHE_TTL = 10; // 10 seconds - too many fetches
// Bad: Too long (stale data)
const LOCK_TTL = 3600; // 1 hour - worker crash holds lock too long
const CACHE_TTL = 86400; // 24 hours - flag changes take too long to propagateRelease locks in finally blocks or on shutdown:
// Good: Always release
async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
try {
await this.redis.set('flags', JSON.stringify(data));
} finally {
await this.redis.del('lock'); // Always release
}
}
// Good: Release on shutdown
async shutdown(): Promise<void> {
try {
await this.redis.del('lock');
} catch (error) {
console.error('Error releasing lock:', error);
}
}Implement lock expiration to prevent deadlocks:
// Good: Locks expire automatically
await this.redis.set('lock', '1', 'EX', 60, 'NX'); // Expires in 60 seconds
// Good: Check lock age before respecting it
const lockAge = await this.getLockAge('lock');
if (lockAge > 60000) {
await this.redis.del('lock'); // Remove stale lock
return true; // Can fetch
}Track cache hit rates and fetch patterns:
class MonitoredCache implements FlagDefinitionCacheProvider {
private hits: number = 0;
private misses: number = 0;
private fetches: number = 0;
async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
const data = await this.getFromCache();
if (data) {
this.hits++;
} else {
this.misses++;
}
return data;
}
async shouldFetchFlagDefinitions(): Promise<boolean> {
const should = await this.acquireLock();
if (should) {
this.fetches++;
}
return should;
}
getMetrics() {
return {
hits: this.hits,
misses: this.misses,
fetches: this.fetches,
hitRate: this.hits / (this.hits + this.misses)
};
}
}Verify your implementation handles cache failures gracefully:
describe('RedisFlagCache', () => {
it('should handle Redis connection failure', async () => {
const cache = new RedisFlagCache(disconnectedRedis, 'test');
// Should not throw, should return undefined
const data = await cache.getFlagDefinitions();
expect(data).toBeUndefined();
});
it('should continue flag evaluation if cache fails', async () => {
const cache = new RedisFlagCache(disconnectedRedis, 'test');
const client = new PostHog('test_key', {
flagDefinitionCacheProvider: cache
});
// Should fall back to direct API fetch
const enabled = await client.isFeatureEnabled('flag', 'user');
expect(enabled).toBeDefined();
});
});Compress flag definitions to reduce memory and network usage:
import zlib from 'zlib';
import { promisify } from 'util';
const gzip = promisify(zlib.gzip);
const gunzip = promisify(zlib.gunzip);
class CompressedRedisCache implements FlagDefinitionCacheProvider {
async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
const json = JSON.stringify(data);
const compressed = await gzip(json);
await this.redis.set('flags', compressed, 'EX', 300);
}
async getFlagDefinitions(): Promise<FlagDefinitionCacheData | undefined> {
const compressed = await this.redis.getBuffer('flags');
if (compressed) {
const json = await gunzip(compressed);
return JSON.parse(json.toString());
}
return undefined;
}
// ... other methods
}Document TTLs, coordination strategy, and failure modes:
/**
* Redis-based cache provider for feature flag definitions.
*
* Coordination: Distributed lock using Redis SET NX
* Cache TTL: 5 minutes
* Lock TTL: 60 seconds
*
* Failure modes:
* - Redis unavailable: Falls back to direct API fetch
* - Lock timeout: Lock expires automatically after 60s
* - Stale cache: Expires after 5 minutes
*/
class RedisFlagCache implements FlagDefinitionCacheProvider {
// ...
}If the cache isn't being used:
const client = new PostHog('key', {
flagDefinitionCacheProvider: cache // Must be set
});getFlagDefinitions() returns data:const data = await cache.getFlagDefinitions();
console.log('Cache data:', data); // Should not be undefined// Manually trigger a fetch
const flags = await client.getAllFlags('test-user');
// Now check cache
const cached = await cache.getFlagDefinitions();
console.log('Cached:', cached); // Should have dataIf multiple workers are fetching simultaneously:
shouldFetchFlagDefinitions() coordination:// Test lock acquisition
const worker1 = await cache.shouldFetchFlagDefinitions();
const worker2 = await cache.shouldFetchFlagDefinitions();
console.log('Worker 1:', worker1); // Should be true
console.log('Worker 2:', worker2); // Should be falseawait cache.onFlagDefinitionsReceived(data);
// Lock should be released now
const canFetch = await cache.shouldFetchFlagDefinitions();
console.log('Can fetch:', canFetch); // Should be true eventually// Ensure locks expire
setTimeout(async () => {
const canFetch = await cache.shouldFetchFlagDefinitions();
console.log('After TTL:', canFetch); // Should be true
}, LOCK_TTL + 1000);If cached flags become stale:
// Verify cache expiration
const oldData = await cache.getFlagDefinitions();
// Wait for TTL
await sleep(CACHE_TTL + 1000);
const newData = await cache.getFlagDefinitions();
console.log('Expired:', newData === undefined); // Should be trueonFlagDefinitionsReceived() is called:async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
console.log('Caching new definitions:', data);
await this.store(data);
}async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
try {
await this.store(data);
} catch (error) {
console.error('Failed to cache:', error); // Check for errors
throw error; // Re-throw to see in logs
}
}If memory usage grows over time:
shutdown() cleans up:async shutdown(): Promise<void> {
await this.redis.quit(); // Close connections
this.cache.clear(); // Clear in-memory data
}class LimitedCache implements FlagDefinitionCacheProvider {
private maxSize = 1024 * 1024; // 1MB
async onFlagDefinitionsReceived(data: FlagDefinitionCacheData): Promise<void> {
const json = JSON.stringify(data);
if (Buffer.byteLength(json) > this.maxSize) {
console.warn('Cache data exceeds max size, skipping');
return;
}
await this.store(data);
}
}