File size: 6,579 Bytes
0bfe2e3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
import { createLogger } from './logger';
import { Env } from './env';

const logger = createLogger('cache');

function formatBytes(bytes: number, decimals: number = 2): string {
  if (!+bytes) return '0 Bytes';

  const k = 1024;
  const dm = decimals < 0 ? 0 : decimals;
  const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];

  const i = Math.floor(Math.log(bytes) / Math.log(k));

  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}

class CacheItem<T> {
  constructor(
    public value: T,
    public lastAccessed: number,
    public ttl: number // Time-To-Live in milliseconds
  ) {}
}

export class Cache<K, V> {
  private static instances: Map<string, any> = new Map();
  private cache: Map<K, CacheItem<V>>;
  private maxSize: number;
  private static isStatsLoopRunning: boolean = false;

  private constructor(maxSize: number) {
    this.cache = new Map<K, CacheItem<V>>();
    this.maxSize = maxSize;
    Cache.startStatsLoop();
  }
  private static startStatsLoop() {
    if (Cache.isStatsLoopRunning) {
      return;
    }
    Cache.isStatsLoopRunning = true;
    const interval = Env.LOG_CACHE_STATS_INTERVAL * 60 * 1000; // Convert minutes to ms
    const runAndReschedule = () => {
      Cache.stats();

      const delay = interval - (Date.now() % interval);
      setTimeout(runAndReschedule, delay).unref();
    };
    const initialDelay = interval - (Date.now() % interval);
    setTimeout(runAndReschedule, initialDelay).unref();
  }

  /**
   * Get an instance of the cache with a specific name
   * @param name Unique identifier for this cache instance
   * @param maxSize Maximum size of the cache (only used when creating a new instance)
   */
  public static getInstance<K, V>(
    name: string,
    maxSize: number = Env.DEFAULT_MAX_CACHE_SIZE
  ): Cache<K, V> {
    if (!this.instances.has(name)) {
      logger.debug(`Creating new cache instance: ${name}`);
      this.instances.set(name, new Cache<K, V>(maxSize));
    }
    return this.instances.get(name) as Cache<K, V>;
  }

  /**
   * Gets the statistics of the cache in use by the program. returns a formatted string containing a list of all cache instances
   * and their currently held items, max items
   */
  public static stats() {
    if (!this.instances || this.instances.size === 0) {
      return;
    }

    let grandTotalItems = 0;
    let grandTotalSize = 0;

    const header = [
      '╔══════════════════════╀══════════╀═════════════════╀═════════════════╗',
      'β•‘      Cache Name      β”‚  Items   β”‚    Max Size     β”‚  Estimated Size β•‘',
      '╠══════════════════════β•ͺ══════════β•ͺ═════════════════β•ͺ═════════════════╣',
    ];

    const bodyLines = Array.from(this.instances.entries()).map(
      ([name, cache]) => {
        let instanceSize = 0;
        for (const item of cache.cache.values()) {
          try {
            // Estimate object size by getting the byte length of its JSON string representation.
            // This is an approximation but is effective for many use cases.
            instanceSize += Buffer.byteLength(JSON.stringify(item), 'utf8');
          } catch (e) {
            // Could fail on circular references. In that case, we add 0.
            instanceSize += 0;
          }
        }

        grandTotalItems += cache.cache.size;
        grandTotalSize += instanceSize;

        const nameStr = name.padEnd(20);
        const itemsStr = String(cache.cache.size).padEnd(8);
        const maxSizeStr = String(cache.maxSize ?? '-').padEnd(15);
        const estSizeStr = formatBytes(instanceSize).padEnd(15);

        return `β•‘ ${nameStr} β”‚ ${itemsStr} β”‚ ${maxSizeStr} β”‚ ${estSizeStr} β•‘`;
      }
    );

    const footer = [
      'β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•§β•β•β•β•β•β•β•β•β•β•β•§β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•§β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•',
      `  Summary: ${this.instances.size} cache instance(s), ${grandTotalItems} total items, Est. Total Size: ${formatBytes(grandTotalSize)}`,
    ];

    const lines = [...header, ...bodyLines, ...footer];
    logger.verbose(lines.join('\n'));
  }

  /**
   * Wrap a function with caching logic by immediately executing it with the provided arguments.
   * @param fn The function to wrap
   * @param key A unique key for caching
   * @param ttl Time-To-Live in seconds for the cached value
   * @param args The arguments to pass to the function
   */
  async wrap<T extends (...args: any[]) => any>(
    fn: T,
    key: K,
    ttl: number,
    ...args: Parameters<T>
  ): Promise<ReturnType<T>> {
    const cachedValue = this.get(key);
    if (cachedValue !== undefined) {
      return cachedValue as ReturnType<T>;
    }
    const result = await fn(...args);
    this.set(key, result, ttl);
    return result;
  }

  get(key: K, updateTTL: boolean = true): V | undefined {
    const item = this.cache.get(key);
    if (item) {
      const now = Date.now();
      if (now - item.lastAccessed > item.ttl) {
        this.cache.delete(key);
        return undefined;
      }
      if (updateTTL) {
        item.lastAccessed = now;
      }
      return item.value;
    }
    return undefined;
  }

  /**
   * Set a value in the cache with a specific TTL
   * @param key The key to set the value for
   * @param value The value to set
   * @param ttl The TTL in seconds
   */
  set(key: K, value: V, ttl: number): void {
    if (this.cache.size >= this.maxSize) {
      this.evict();
    }
    this.cache.set(key, new CacheItem<V>(value, Date.now(), ttl * 1000));
  }

  /**
   * Update the value of an existing key in the cache without changing the TTL
   * @param key The key to update
   * @param value The new value
   */
  update(key: K, value: V): void {
    const item = this.cache.get(key);
    if (item) {
      item.value = value;
    }
  }

  clear(): void {
    this.cache.clear();
  }

  private evict(): void {
    let oldestKey: K | undefined;
    let oldestTime = Infinity;

    for (const [key, item] of this.cache.entries()) {
      if (item.lastAccessed < oldestTime) {
        oldestTime = item.lastAccessed;
        oldestKey = key;
      }
    }

    if (oldestKey !== undefined) {
      this.cache.delete(oldestKey);
    }
  }
}