/** * Centralized Storage Tier Policy * * This module centralizes all storage tier policy decisions for the postgres.do * tiered storage architecture: * * - HOT: In-DO SQLite Blobs (sync) / Cloudflare Cache API (async) * - WARM: Durable Object storage (persistent) * - COLD: R2 object storage (archival) * * The policy module provides a single source of truth for: * - Promotion decisions (cold -> warm -> hot) * - Demotion decisions (hot -> warm -> cold) * - TTL configuration per tier * - Threshold configuration for access-based tiering * * @module @dotdo/postgres-shared/storage-policy */ /** * Storage tier identifiers * - hot: Cloudflare Cache API (FREE, fastest, ephemeral) * - warm: Durable Object Storage (fast, persistent, limited) * - cold: R2 (durable, unlimited, slower) */ export type StorageTier = 'hot' | 'warm' | 'cold' /** * Reason for tier movement */ export type TierMovementReason = | 'access_threshold' // Promoted due to frequent access | 'time_window' // Promoted/demoted based on time window access | 'ttl_expired' // Demoted due to TTL expiration | 'lru_eviction' // Demoted due to LRU eviction (capacity) | 'manual' // Manual tier movement | 'cost_optimization' // Moved for cost reasons | 'initial_placement' // Initial placement decision /** * Access statistics for a page/resource */ export interface AccessStats { /** Total number of times accessed */ totalAccessCount: number /** Number of accesses within the time window */ windowAccessCount: number /** Timestamp of first access (ms since epoch) */ firstAccessTime: number /** Timestamp of last access (ms since epoch) */ lastAccessTime: number /** Size of the resource in bytes */ sizeBytes?: number } /** * Decision about whether to promote a resource to a hotter tier */ export interface PromotionDecision { /** Whether promotion should happen */ shouldPromote: boolean /** Target tier for promotion (if shouldPromote is true) */ targetTier?: StorageTier /** Reason for the decision */ reason: TierMovementReason | 'no_action' /** Human-readable explanation */ explanation?: string } /** * Decision about whether to demote a resource to a colder tier */ export interface DemotionDecision { /** Whether demotion should happen */ shouldDemote: boolean /** Target tier for demotion (if shouldDemote is true) */ targetTier?: StorageTier /** Reason for the decision */ reason: TierMovementReason | 'no_action' /** Human-readable explanation */ explanation?: string } /** * Decision about initial tier placement for a new resource */ export interface PlacementDecision { /** Tier to place the resource in */ tier: StorageTier /** Reason for the decision */ reason: TierMovementReason /** Human-readable explanation */ explanation?: string } /** * Configuration for time-to-live settings per tier */ export interface TierTTLConfig { /** TTL for hot tier in milliseconds */ hotTTLMs: number /** TTL for warm tier in milliseconds */ warmTTLMs: number /** TTL for cold tier in milliseconds (typically Infinity) */ coldTTLMs: number } /** * Configuration for promotion thresholds */ export interface PromotionThresholdConfig { /** Access count threshold for cold -> warm promotion */ coldToWarmAccessCount: number /** Access count threshold for warm -> hot promotion */ warmToHotAccessCount: number /** Time window in milliseconds for warm -> hot promotion */ warmToHotTimeWindowMs: number } /** * Configuration for capacity limits */ export interface CapacityConfig { /** Maximum number of pages/resources in hot tier */ maxHotEntries?: number /** Maximum number of pages/resources in warm tier */ maxWarmEntries?: number /** Maximum total bytes in hot tier */ maxHotBytes?: number /** Maximum total bytes in warm tier */ maxWarmBytes?: number } /** * Complete tier policy configuration */ export interface TierPolicyConfig { /** TTL settings per tier */ ttl: TierTTLConfig /** Promotion thresholds */ promotion: PromotionThresholdConfig /** Optional capacity limits */ capacity?: CapacityConfig /** Default tier for new resources */ defaultTier?: StorageTier } /** * Interface for tier policy implementations * * This interface allows different policy strategies to be swapped: * - DefaultTierPolicy: Balanced approach * - AggressiveCachePolicy: Maximize hot tier usage * - ConservativePolicy: Minimize tier movement * - AdaptiveTierPolicy: Learn from access patterns */ export interface TierPolicy { /** * Decide whether to promote a resource to a hotter tier * * @param currentTier - The current tier of the resource * @param stats - Access statistics for the resource * @returns Promotion decision */ shouldPromote(currentTier: StorageTier, stats: AccessStats): PromotionDecision /** * Decide whether to demote a resource to a colder tier * * @param currentTier - The current tier of the resource * @param stats - Access statistics for the resource * @param currentTime - Current timestamp (for TTL calculation) * @returns Demotion decision */ shouldDemote(currentTier: StorageTier, stats: AccessStats, currentTime?: number): DemotionDecision /** * Decide initial tier placement for a new resource * * @param sizeBytes - Size of the resource in bytes * @param hints - Optional hints about expected access patterns * @returns Placement decision */ getInitialPlacement(sizeBytes?: number, hints?: PlacementHints): PlacementDecision /** * Get the TTL for a specific tier * * @param tier - The storage tier * @returns TTL in milliseconds */ getTTL(tier: StorageTier): number /** * Get the full policy configuration */ getConfig(): TierPolicyConfig } /** * Hints for initial placement decisions */ export interface PlacementHints { /** Expected access frequency (accesses per minute) */ expectedAccessFrequency?: number /** Whether this is temporary data */ isTemporary?: boolean /** Whether this is archival data */ isArchival?: boolean /** Preferred tier (can be overridden by policy) */ preferredTier?: StorageTier } /** * Current tier statistics for capacity-based decisions */ export interface TierStats { /** Number of entries in hot tier */ hotEntryCount: number /** Number of entries in warm tier */ warmEntryCount: number /** Number of entries in cold tier */ coldEntryCount: number /** Total bytes in hot tier */ hotBytes?: number /** Total bytes in warm tier */ warmBytes?: number /** Total bytes in cold tier */ coldBytes?: number } /** * Default tier policy configuration values * * These defaults are tuned for typical edge database workloads: * - Hot tier: 5 minute TTL (cache frequently accessed pages) * - Warm tier: 1 hour TTL (keep active pages in DO) * - Cold tier: No TTL (R2 is durable storage) */ export const DEFAULT_TIER_POLICY_CONFIG: TierPolicyConfig = { ttl: { hotTTLMs: 5 * 60 * 1000, // 5 minutes warmTTLMs: 60 * 60 * 1000, // 1 hour coldTTLMs: Infinity, // Never expires }, promotion: { coldToWarmAccessCount: 3, // Promote after 3 accesses warmToHotAccessCount: 5, // Promote after 5 accesses in window warmToHotTimeWindowMs: 60 * 1000, // 1 minute time window }, defaultTier: 'warm', } /** * Aggressive caching policy - maximizes hot tier usage * * Use this when: * - You have high read-to-write ratio * - Cache API costs are not a concern (it's FREE on Cloudflare!) * - Low latency is critical */ export const AGGRESSIVE_CACHE_POLICY_CONFIG: TierPolicyConfig = { ttl: { hotTTLMs: 15 * 60 * 1000, // 15 minutes warmTTLMs: 30 * 60 * 1000, // 30 minutes coldTTLMs: Infinity, }, promotion: { coldToWarmAccessCount: 1, // Promote immediately warmToHotAccessCount: 2, // Promote after 2 accesses warmToHotTimeWindowMs: 5 * 60 * 1000, // 5 minute window }, defaultTier: 'hot', } /** * Conservative policy - minimizes tier movement * * Use this when: * - You have limited cache capacity * - Write-heavy workloads * - Data changes frequently */ export const CONSERVATIVE_POLICY_CONFIG: TierPolicyConfig = { ttl: { hotTTLMs: 60 * 1000, // 1 minute warmTTLMs: 5 * 60 * 1000, // 5 minutes coldTTLMs: Infinity, }, promotion: { coldToWarmAccessCount: 10, // High threshold warmToHotAccessCount: 20, // Very high threshold warmToHotTimeWindowMs: 30 * 1000, // 30 second window }, defaultTier: 'cold', } /** * Cost-optimized policy - balances performance and cost * * Use this when: * - You want to minimize R2 operations * - Hot tier is free (Cloudflare Cache API) * - Warm tier (DO) has storage limits */ export const COST_OPTIMIZED_POLICY_CONFIG: TierPolicyConfig = { ttl: { hotTTLMs: 30 * 60 * 1000, // 30 minutes (maximize free cache) warmTTLMs: 2 * 60 * 60 * 1000, // 2 hours (minimize R2 reads) coldTTLMs: Infinity, }, promotion: { coldToWarmAccessCount: 2, // Quick promotion from R2 warmToHotAccessCount: 3, // Quick promotion to free cache warmToHotTimeWindowMs: 2 * 60 * 1000, // 2 minute window }, capacity: { maxHotEntries: 10000, // Limit hot tier entries maxWarmEntries: 1000, // Smaller warm tier }, defaultTier: 'warm', } /** * Default tier policy implementation * * Provides a balanced approach to tier management with configurable * thresholds, TTLs, and capacity limits. * * @example * ```typescript * const policy = new DefaultTierPolicy() * * // Check if a page should be promoted * const stats: AccessStats = { * totalAccessCount: 10, * windowAccessCount: 5, * firstAccessTime: Date.now() - 60000, * lastAccessTime: Date.now(), * } * const decision = policy.shouldPromote('warm', stats) * if (decision.shouldPromote) { * console.log(`Promote to ${decision.targetTier}: ${decision.explanation}`) * } * ``` */ export class DefaultTierPolicy implements TierPolicy { private config: TierPolicyConfig private tierStats?: TierStats constructor(config: Partial = {}) { const newConfig: TierPolicyConfig = { ttl: { ...DEFAULT_TIER_POLICY_CONFIG.ttl, ...config.ttl }, promotion: { ...DEFAULT_TIER_POLICY_CONFIG.promotion, ...config.promotion }, } const defaultTier = config.defaultTier ?? DEFAULT_TIER_POLICY_CONFIG.defaultTier if (defaultTier !== undefined) { newConfig.defaultTier = defaultTier } if (config.capacity !== undefined) { newConfig.capacity = config.capacity } this.config = newConfig } /** * Update tier statistics for capacity-based decisions */ setTierStats(stats: TierStats): void { this.tierStats = stats } /** * Decide whether to promote a resource to a hotter tier */ shouldPromote(currentTier: StorageTier, stats: AccessStats): PromotionDecision { // Can't promote from hot tier if (currentTier === 'hot') { return { shouldPromote: false, reason: 'no_action', explanation: 'Already in hottest tier', } } // Check cold -> warm promotion if (currentTier === 'cold') { if (stats.totalAccessCount >= this.config.promotion.coldToWarmAccessCount) { // Check capacity before promoting if (this.isWarmTierAtCapacity()) { return { shouldPromote: false, reason: 'no_action', explanation: 'Warm tier at capacity', } } return { shouldPromote: true, targetTier: 'warm', reason: 'access_threshold', explanation: `Access count ${stats.totalAccessCount} >= threshold ${this.config.promotion.coldToWarmAccessCount}`, } } } // Check warm -> hot promotion (time-window based) if (currentTier === 'warm') { if (stats.windowAccessCount >= this.config.promotion.warmToHotAccessCount) { // Check capacity before promoting if (this.isHotTierAtCapacity()) { return { shouldPromote: false, reason: 'no_action', explanation: 'Hot tier at capacity', } } return { shouldPromote: true, targetTier: 'hot', reason: 'time_window', explanation: `Window access count ${stats.windowAccessCount} >= threshold ${this.config.promotion.warmToHotAccessCount} in ${this.config.promotion.warmToHotTimeWindowMs}ms window`, } } } return { shouldPromote: false, reason: 'no_action', explanation: 'Access thresholds not met', } } /** * Decide whether to demote a resource to a colder tier */ shouldDemote( currentTier: StorageTier, stats: AccessStats, currentTime: number = Date.now() ): DemotionDecision { // Can't demote from cold tier if (currentTier === 'cold') { return { shouldDemote: false, reason: 'no_action', explanation: 'Already in coldest tier', } } const ttl = this.getTTL(currentTier) const idleTime = currentTime - stats.lastAccessTime // Check TTL-based demotion if (idleTime >= ttl) { const targetTier = this.getDemotionTarget(currentTier) return { shouldDemote: true, targetTier, reason: 'ttl_expired', explanation: `Idle time ${idleTime}ms >= TTL ${ttl}ms`, } } return { shouldDemote: false, reason: 'no_action', explanation: `Idle time ${idleTime}ms < TTL ${ttl}ms`, } } /** * Decide initial tier placement for a new resource */ getInitialPlacement(_sizeBytes?: number, hints?: PlacementHints): PlacementDecision { // Respect explicit archival hint if (hints?.isArchival) { return { tier: 'cold', reason: 'initial_placement', explanation: 'Archival data placed in cold tier', } } // Respect explicit temporary hint if (hints?.isTemporary) { return { tier: 'hot', reason: 'initial_placement', explanation: 'Temporary data placed in hot tier', } } // High expected access frequency -> start in hot tier if (hints?.expectedAccessFrequency && hints.expectedAccessFrequency > 10) { if (!this.isHotTierAtCapacity()) { return { tier: 'hot', reason: 'initial_placement', explanation: `High expected access frequency (${hints.expectedAccessFrequency}/min)`, } } } // Respect preferred tier if capacity allows if (hints?.preferredTier) { if (hints.preferredTier === 'hot' && !this.isHotTierAtCapacity()) { return { tier: 'hot', reason: 'initial_placement', explanation: 'Preferred tier: hot', } } if (hints.preferredTier === 'warm' && !this.isWarmTierAtCapacity()) { return { tier: 'warm', reason: 'initial_placement', explanation: 'Preferred tier: warm', } } if (hints.preferredTier === 'cold') { return { tier: 'cold', reason: 'initial_placement', explanation: 'Preferred tier: cold', } } } // Use default tier if capacity allows const defaultTier = this.config.defaultTier ?? 'warm' if (defaultTier === 'hot' && this.isHotTierAtCapacity()) { return { tier: 'warm', reason: 'initial_placement', explanation: 'Hot tier at capacity, using warm tier', } } if (defaultTier === 'warm' && this.isWarmTierAtCapacity()) { return { tier: 'cold', reason: 'initial_placement', explanation: 'Warm tier at capacity, using cold tier', } } return { tier: defaultTier, reason: 'initial_placement', explanation: `Default tier: ${defaultTier}`, } } /** * Get the TTL for a specific tier */ getTTL(tier: StorageTier): number { switch (tier) { case 'hot': return this.config.ttl.hotTTLMs case 'warm': return this.config.ttl.warmTTLMs case 'cold': return this.config.ttl.coldTTLMs } } /** * Get the full policy configuration */ getConfig(): TierPolicyConfig { const result: TierPolicyConfig = { ttl: { ...this.config.ttl }, promotion: { ...this.config.promotion }, } if (this.config.defaultTier !== undefined) { result.defaultTier = this.config.defaultTier } if (this.config.capacity) { result.capacity = { ...this.config.capacity } } return result } /** * Get the target tier for demotion */ private getDemotionTarget(currentTier: StorageTier): StorageTier { switch (currentTier) { case 'hot': return 'warm' case 'warm': return 'cold' case 'cold': return 'cold' // Already at coldest } } /** * Check if hot tier is at capacity */ private isHotTierAtCapacity(): boolean { if (!this.config.capacity || !this.tierStats) { return false } if ( this.config.capacity.maxHotEntries && this.tierStats.hotEntryCount >= this.config.capacity.maxHotEntries ) { return true } if ( this.config.capacity.maxHotBytes && this.tierStats.hotBytes && this.tierStats.hotBytes >= this.config.capacity.maxHotBytes ) { return true } return false } /** * Check if warm tier is at capacity */ private isWarmTierAtCapacity(): boolean { if (!this.config.capacity || !this.tierStats) { return false } if ( this.config.capacity.maxWarmEntries && this.tierStats.warmEntryCount >= this.config.capacity.maxWarmEntries ) { return true } if ( this.config.capacity.maxWarmBytes && this.tierStats.warmBytes && this.tierStats.warmBytes >= this.config.capacity.maxWarmBytes ) { return true } return false } } /** * Aggressive caching policy implementation * * Maximizes usage of the hot tier (Cloudflare Cache API) which is FREE. * This policy promotes aggressively and keeps data in cache longer. * * @example * ```typescript * const policy = new AggressiveCachePolicy() * * // Will promote after just 1-2 accesses * const decision = policy.shouldPromote('cold', { totalAccessCount: 1, ... }) * // decision.shouldPromote === true * ``` */ export class AggressiveCachePolicy extends DefaultTierPolicy { constructor(config: Partial = {}) { super({ ...AGGRESSIVE_CACHE_POLICY_CONFIG, ...config, ttl: { ...AGGRESSIVE_CACHE_POLICY_CONFIG.ttl, ...config.ttl }, promotion: { ...AGGRESSIVE_CACHE_POLICY_CONFIG.promotion, ...config.promotion }, }) } } /** * Conservative policy implementation * * Minimizes tier movement for write-heavy workloads or when cache * capacity is limited. * * @example * ```typescript * const policy = new ConservativePolicy() * * // Requires many accesses before promotion * const decision = policy.shouldPromote('cold', { totalAccessCount: 5, ... }) * // decision.shouldPromote === false (needs 10+) * ``` */ export class ConservativePolicy extends DefaultTierPolicy { constructor(config: Partial = {}) { super({ ...CONSERVATIVE_POLICY_CONFIG, ...config, ttl: { ...CONSERVATIVE_POLICY_CONFIG.ttl, ...config.ttl }, promotion: { ...CONSERVATIVE_POLICY_CONFIG.promotion, ...config.promotion }, }) } } /** * Cost-optimized policy implementation * * Balances performance and cost by maximizing free cache usage * while minimizing expensive R2 operations. * * @example * ```typescript * const policy = new CostOptimizedPolicy() * * // Quick promotion from cold (R2) to avoid repeated R2 reads * const decision = policy.shouldPromote('cold', { totalAccessCount: 2, ... }) * // decision.shouldPromote === true * ``` */ export class CostOptimizedPolicy extends DefaultTierPolicy { constructor(config: Partial = {}) { super({ ...COST_OPTIMIZED_POLICY_CONFIG, ...config, ttl: { ...COST_OPTIMIZED_POLICY_CONFIG.ttl, ...config.ttl }, promotion: { ...COST_OPTIMIZED_POLICY_CONFIG.promotion, ...config.promotion }, }) } } /** * Factory function to create a tier policy from a preset name * * @param preset - Name of the preset policy * @param overrides - Optional configuration overrides * @returns Configured TierPolicy instance * * @example * ```typescript * // Use default policy * const policy = createTierPolicy('default') * * // Use aggressive caching with custom TTL * const policy = createTierPolicy('aggressive', { * ttl: { hotTTLMs: 30 * 60 * 1000 } // 30 minute hot TTL * }) * ``` */ export function createTierPolicy( preset: 'default' | 'aggressive' | 'conservative' | 'cost-optimized' = 'default', overrides?: Partial ): TierPolicy { switch (preset) { case 'aggressive': return new AggressiveCachePolicy(overrides) case 'conservative': return new ConservativePolicy(overrides) case 'cost-optimized': return new CostOptimizedPolicy(overrides) case 'default': default: return new DefaultTierPolicy(overrides) } } /** * Utility to calculate access statistics from access records * * @param accessTimes - Array of access timestamps (ms since epoch) * @param timeWindowMs - Time window for window access count calculation * @param currentTime - Current timestamp (defaults to Date.now()) * @returns Calculated access statistics */ export function calculateAccessStats( accessTimes: number[], timeWindowMs: number, currentTime: number = Date.now() ): AccessStats { if (accessTimes.length === 0) { return { totalAccessCount: 0, windowAccessCount: 0, firstAccessTime: currentTime, lastAccessTime: currentTime, } } const sorted = [...accessTimes].sort((a, b) => a - b) const windowCutoff = currentTime - timeWindowMs const windowAccesses = sorted.filter((t) => t >= windowCutoff) return { totalAccessCount: sorted.length, windowAccessCount: windowAccesses.length, firstAccessTime: sorted[0]!, lastAccessTime: sorted[sorted.length - 1]!, } } /** * Merge tier policy configurations * * @param base - Base configuration * @param overrides - Override values * @returns Merged configuration */ export function mergeTierPolicyConfig( base: TierPolicyConfig, overrides: Partial ): TierPolicyConfig { const result: TierPolicyConfig = { ttl: { ...base.ttl, ...overrides.ttl }, promotion: { ...base.promotion, ...overrides.promotion }, } const mergedDefaultTier = overrides.defaultTier ?? base.defaultTier if (mergedDefaultTier !== undefined) { result.defaultTier = mergedDefaultTier } const mergedCapacity = overrides.capacity ?? base.capacity if (mergedCapacity !== undefined) { result.capacity = mergedCapacity } return result }