wip library to store cold objects in s3, warm objects on disk, and hot objects in memory
nodejs typescript
at main 20 kB view raw
1import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 2import { TieredStorage } from '../src/TieredStorage.js'; 3import { MemoryStorageTier } from '../src/tiers/MemoryStorageTier.js'; 4import { DiskStorageTier } from '../src/tiers/DiskStorageTier.js'; 5import { rm } from 'node:fs/promises'; 6 7describe('TieredStorage', () => { 8 const testDir = './test-cache'; 9 10 afterEach(async () => { 11 await rm(testDir, { recursive: true, force: true }); 12 }); 13 14 describe('Basic Operations', () => { 15 it('should store and retrieve data', async () => { 16 const storage = new TieredStorage({ 17 tiers: { 18 hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }), 19 warm: new DiskStorageTier({ directory: `${testDir}/warm` }), 20 cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 21 }, 22 }); 23 24 await storage.set('test-key', { message: 'Hello, world!' }); 25 const result = await storage.get('test-key'); 26 27 expect(result).toEqual({ message: 'Hello, world!' }); 28 }); 29 30 it('should return null for non-existent key', async () => { 31 const storage = new TieredStorage({ 32 tiers: { 33 cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 34 }, 35 }); 36 37 const result = await storage.get('non-existent'); 38 expect(result).toBeNull(); 39 }); 40 41 it('should delete data from all tiers', async () => { 42 const storage = new TieredStorage({ 43 tiers: { 44 hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }), 45 warm: new DiskStorageTier({ directory: `${testDir}/warm` }), 46 cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 47 }, 48 }); 49 50 await storage.set('test-key', { data: 'test' }); 51 await storage.delete('test-key'); 52 const result = await storage.get('test-key'); 53 54 expect(result).toBeNull(); 55 }); 56 57 it('should check if key exists', async () => { 58 const storage = new TieredStorage({ 59 tiers: { 60 cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 61 }, 62 }); 63 64 await storage.set('test-key', { data: 'test' }); 65 66 expect(await storage.exists('test-key')).toBe(true); 67 expect(await storage.exists('non-existent')).toBe(false); 68 }); 69 }); 70 71 describe('Cascading Write', () => { 72 it('should write to all configured tiers', async () => { 73 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 74 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 75 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 76 77 const storage = new TieredStorage({ 78 tiers: { hot, warm, cold }, 79 }); 80 81 await storage.set('test-key', { data: 'test' }); 82 83 // Verify data exists in all tiers 84 expect(await hot.exists('test-key')).toBe(true); 85 expect(await warm.exists('test-key')).toBe(true); 86 expect(await cold.exists('test-key')).toBe(true); 87 }); 88 89 it('should skip tiers when specified', async () => { 90 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 91 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 92 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 93 94 const storage = new TieredStorage({ 95 tiers: { hot, warm, cold }, 96 }); 97 98 // Skip hot tier 99 await storage.set('test-key', { data: 'test' }, { skipTiers: ['hot'] }); 100 101 expect(await hot.exists('test-key')).toBe(false); 102 expect(await warm.exists('test-key')).toBe(true); 103 expect(await cold.exists('test-key')).toBe(true); 104 }); 105 }); 106 107 describe('Bubbling Read', () => { 108 it('should read from hot tier first', async () => { 109 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 110 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 111 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 112 113 const storage = new TieredStorage({ 114 tiers: { hot, warm, cold }, 115 }); 116 117 await storage.set('test-key', { data: 'test' }); 118 const result = await storage.getWithMetadata('test-key'); 119 120 expect(result?.source).toBe('hot'); 121 expect(result?.data).toEqual({ data: 'test' }); 122 }); 123 124 it('should fall back to warm tier on hot miss', async () => { 125 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 126 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 127 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 128 129 const storage = new TieredStorage({ 130 tiers: { hot, warm, cold }, 131 }); 132 133 // Write to warm and cold, skip hot 134 await storage.set('test-key', { data: 'test' }, { skipTiers: ['hot'] }); 135 136 const result = await storage.getWithMetadata('test-key'); 137 138 expect(result?.source).toBe('warm'); 139 expect(result?.data).toEqual({ data: 'test' }); 140 }); 141 142 it('should fall back to cold tier on hot and warm miss', async () => { 143 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 144 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 145 146 const storage = new TieredStorage({ 147 tiers: { hot, cold }, 148 }); 149 150 // Write only to cold 151 await cold.set( 152 'test-key', 153 new TextEncoder().encode(JSON.stringify({ data: 'test' })), 154 { 155 key: 'test-key', 156 size: 100, 157 createdAt: new Date(), 158 lastAccessed: new Date(), 159 accessCount: 0, 160 compressed: false, 161 checksum: 'abc123', 162 } 163 ); 164 165 const result = await storage.getWithMetadata('test-key'); 166 167 expect(result?.source).toBe('cold'); 168 expect(result?.data).toEqual({ data: 'test' }); 169 }); 170 }); 171 172 describe('Promotion Strategy', () => { 173 it('should eagerly promote data to upper tiers', async () => { 174 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 175 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 176 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 177 178 const storage = new TieredStorage({ 179 tiers: { hot, warm, cold }, 180 promotionStrategy: 'eager', 181 }); 182 183 // Write only to cold 184 await cold.set( 185 'test-key', 186 new TextEncoder().encode(JSON.stringify({ data: 'test' })), 187 { 188 key: 'test-key', 189 size: 100, 190 createdAt: new Date(), 191 lastAccessed: new Date(), 192 accessCount: 0, 193 compressed: false, 194 checksum: 'abc123', 195 } 196 ); 197 198 // Read should promote to hot and warm 199 await storage.get('test-key'); 200 201 expect(await hot.exists('test-key')).toBe(true); 202 expect(await warm.exists('test-key')).toBe(true); 203 }); 204 205 it('should lazily promote data (not automatic)', async () => { 206 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 207 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 208 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 209 210 const storage = new TieredStorage({ 211 tiers: { hot, warm, cold }, 212 promotionStrategy: 'lazy', 213 }); 214 215 // Write only to cold 216 await cold.set( 217 'test-key', 218 new TextEncoder().encode(JSON.stringify({ data: 'test' })), 219 { 220 key: 'test-key', 221 size: 100, 222 createdAt: new Date(), 223 lastAccessed: new Date(), 224 accessCount: 0, 225 compressed: false, 226 checksum: 'abc123', 227 } 228 ); 229 230 // Read should NOT promote to hot and warm 231 await storage.get('test-key'); 232 233 expect(await hot.exists('test-key')).toBe(false); 234 expect(await warm.exists('test-key')).toBe(false); 235 }); 236 }); 237 238 describe('TTL Management', () => { 239 it('should expire data after TTL', async () => { 240 const storage = new TieredStorage({ 241 tiers: { 242 cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 243 }, 244 }); 245 246 // Set with 100ms TTL 247 await storage.set('test-key', { data: 'test' }, { ttl: 100 }); 248 249 // Should exist immediately 250 expect(await storage.get('test-key')).toEqual({ data: 'test' }); 251 252 // Wait for expiration 253 await new Promise((resolve) => setTimeout(resolve, 150)); 254 255 // Should be null after expiration 256 expect(await storage.get('test-key')).toBeNull(); 257 }); 258 259 it('should renew TTL with touch', async () => { 260 const storage = new TieredStorage({ 261 tiers: { 262 cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 263 }, 264 defaultTTL: 100, 265 }); 266 267 await storage.set('test-key', { data: 'test' }); 268 269 // Wait 50ms 270 await new Promise((resolve) => setTimeout(resolve, 50)); 271 272 // Renew TTL 273 await storage.touch('test-key', 200); 274 275 // Wait another 100ms (would have expired without touch) 276 await new Promise((resolve) => setTimeout(resolve, 100)); 277 278 // Should still exist 279 expect(await storage.get('test-key')).toEqual({ data: 'test' }); 280 }); 281 }); 282 283 describe('Prefix Invalidation', () => { 284 it('should invalidate all keys with prefix', async () => { 285 const storage = new TieredStorage({ 286 tiers: { 287 hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }), 288 cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 289 }, 290 }); 291 292 await storage.set('user:123', { name: 'Alice' }); 293 await storage.set('user:456', { name: 'Bob' }); 294 await storage.set('post:789', { title: 'Test' }); 295 296 const deleted = await storage.invalidate('user:'); 297 298 expect(deleted).toBe(2); 299 expect(await storage.exists('user:123')).toBe(false); 300 expect(await storage.exists('user:456')).toBe(false); 301 expect(await storage.exists('post:789')).toBe(true); 302 }); 303 }); 304 305 describe('Compression', () => { 306 it('should compress data when enabled', async () => { 307 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 308 309 const storage = new TieredStorage({ 310 tiers: { cold }, 311 compression: true, 312 }); 313 314 const largeData = { data: 'x'.repeat(10000) }; 315 const result = await storage.set('test-key', largeData); 316 317 // Check that compressed flag is set 318 expect(result.metadata.compressed).toBe(true); 319 320 // Verify data can be retrieved correctly 321 const retrieved = await storage.get('test-key'); 322 expect(retrieved).toEqual(largeData); 323 }); 324 }); 325 326 describe('Bootstrap', () => { 327 it('should bootstrap hot from warm', async () => { 328 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 329 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 330 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 331 332 const storage = new TieredStorage({ 333 tiers: { hot, warm, cold }, 334 }); 335 336 // Write some data 337 await storage.set('key1', { data: '1' }); 338 await storage.set('key2', { data: '2' }); 339 await storage.set('key3', { data: '3' }); 340 341 // Clear hot tier 342 await hot.clear(); 343 344 // Bootstrap hot from warm 345 const loaded = await storage.bootstrapHot(); 346 347 expect(loaded).toBe(3); 348 expect(await hot.exists('key1')).toBe(true); 349 expect(await hot.exists('key2')).toBe(true); 350 expect(await hot.exists('key3')).toBe(true); 351 }); 352 353 it('should bootstrap warm from cold', async () => { 354 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 355 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 356 357 const storage = new TieredStorage({ 358 tiers: { warm, cold }, 359 }); 360 361 // Write directly to cold 362 await cold.set( 363 'key1', 364 new TextEncoder().encode(JSON.stringify({ data: '1' })), 365 { 366 key: 'key1', 367 size: 100, 368 createdAt: new Date(), 369 lastAccessed: new Date(), 370 accessCount: 0, 371 compressed: false, 372 checksum: 'abc', 373 } 374 ); 375 376 // Bootstrap warm from cold 377 const loaded = await storage.bootstrapWarm({ limit: 10 }); 378 379 expect(loaded).toBe(1); 380 expect(await warm.exists('key1')).toBe(true); 381 }); 382 }); 383 384 describe('Statistics', () => { 385 it('should return statistics for all tiers', async () => { 386 const storage = new TieredStorage({ 387 tiers: { 388 hot: new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }), 389 warm: new DiskStorageTier({ directory: `${testDir}/warm` }), 390 cold: new DiskStorageTier({ directory: `${testDir}/cold` }), 391 }, 392 }); 393 394 await storage.set('key1', { data: 'test1' }); 395 await storage.set('key2', { data: 'test2' }); 396 397 const stats = await storage.getStats(); 398 399 expect(stats.cold.items).toBe(2); 400 expect(stats.warm?.items).toBe(2); 401 expect(stats.hot?.items).toBe(2); 402 }); 403 }); 404 405 describe('Placement Rules', () => { 406 it('should place index.html in all tiers based on rule', async () => { 407 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 408 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 409 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 410 411 const storage = new TieredStorage({ 412 tiers: { hot, warm, cold }, 413 placementRules: [ 414 { pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] }, 415 { pattern: '**', tiers: ['warm', 'cold'] }, 416 ], 417 }); 418 419 await storage.set('site:abc/index.html', { content: 'hello' }); 420 421 expect(await hot.exists('site:abc/index.html')).toBe(true); 422 expect(await warm.exists('site:abc/index.html')).toBe(true); 423 expect(await cold.exists('site:abc/index.html')).toBe(true); 424 }); 425 426 it('should skip hot tier for non-matching files', async () => { 427 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 428 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 429 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 430 431 const storage = new TieredStorage({ 432 tiers: { hot, warm, cold }, 433 placementRules: [ 434 { pattern: '**/index.html', tiers: ['hot', 'warm', 'cold'] }, 435 { pattern: '**', tiers: ['warm', 'cold'] }, 436 ], 437 }); 438 439 await storage.set('site:abc/about.html', { content: 'about' }); 440 441 expect(await hot.exists('site:abc/about.html')).toBe(false); 442 expect(await warm.exists('site:abc/about.html')).toBe(true); 443 expect(await cold.exists('site:abc/about.html')).toBe(true); 444 }); 445 446 it('should match directory patterns', async () => { 447 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 448 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 449 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 450 451 const storage = new TieredStorage({ 452 tiers: { hot, warm, cold }, 453 placementRules: [ 454 { pattern: 'assets/**', tiers: ['warm', 'cold'] }, 455 { pattern: '**', tiers: ['hot', 'warm', 'cold'] }, 456 ], 457 }); 458 459 await storage.set('assets/images/logo.png', { data: 'png' }); 460 await storage.set('index.html', { data: 'html' }); 461 462 // assets/** should skip hot 463 expect(await hot.exists('assets/images/logo.png')).toBe(false); 464 expect(await warm.exists('assets/images/logo.png')).toBe(true); 465 466 // everything else goes to all tiers 467 expect(await hot.exists('index.html')).toBe(true); 468 }); 469 470 it('should match file extension patterns', async () => { 471 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 472 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 473 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 474 475 const storage = new TieredStorage({ 476 tiers: { hot, warm, cold }, 477 placementRules: [ 478 { pattern: '**/*.{jpg,png,gif,mp4}', tiers: ['warm', 'cold'] }, 479 { pattern: '**', tiers: ['hot', 'warm', 'cold'] }, 480 ], 481 }); 482 483 await storage.set('site/hero.png', { data: 'image' }); 484 await storage.set('site/video.mp4', { data: 'video' }); 485 await storage.set('site/index.html', { data: 'html' }); 486 487 // Images and video skip hot 488 expect(await hot.exists('site/hero.png')).toBe(false); 489 expect(await hot.exists('site/video.mp4')).toBe(false); 490 491 // HTML goes everywhere 492 expect(await hot.exists('site/index.html')).toBe(true); 493 }); 494 495 it('should use first matching rule', async () => { 496 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 497 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 498 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 499 500 const storage = new TieredStorage({ 501 tiers: { hot, warm, cold }, 502 placementRules: [ 503 // Specific rule first 504 { pattern: 'assets/critical.css', tiers: ['hot', 'warm', 'cold'] }, 505 // General rule second 506 { pattern: 'assets/**', tiers: ['warm', 'cold'] }, 507 { pattern: '**', tiers: ['warm', 'cold'] }, 508 ], 509 }); 510 511 await storage.set('assets/critical.css', { data: 'css' }); 512 await storage.set('assets/style.css', { data: 'css' }); 513 514 // critical.css matches first rule -> hot 515 expect(await hot.exists('assets/critical.css')).toBe(true); 516 517 // style.css matches second rule -> no hot 518 expect(await hot.exists('assets/style.css')).toBe(false); 519 }); 520 521 it('should allow skipTiers to override placement rules', async () => { 522 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 523 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 524 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 525 526 const storage = new TieredStorage({ 527 tiers: { hot, warm, cold }, 528 placementRules: [ 529 { pattern: '**', tiers: ['hot', 'warm', 'cold'] }, 530 ], 531 }); 532 533 // Explicit skipTiers should override the rule 534 await storage.set('large-file.bin', { data: 'big' }, { skipTiers: ['hot'] }); 535 536 expect(await hot.exists('large-file.bin')).toBe(false); 537 expect(await warm.exists('large-file.bin')).toBe(true); 538 expect(await cold.exists('large-file.bin')).toBe(true); 539 }); 540 541 it('should always include cold tier even if not in rule', async () => { 542 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 543 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 544 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 545 546 const storage = new TieredStorage({ 547 tiers: { hot, warm, cold }, 548 placementRules: [ 549 // Rule doesn't include cold (should be auto-added) 550 { pattern: '**', tiers: ['hot', 'warm'] }, 551 ], 552 }); 553 554 await storage.set('test-key', { data: 'test' }); 555 556 expect(await cold.exists('test-key')).toBe(true); 557 }); 558 559 it('should write to all tiers when no rules match', async () => { 560 const hot = new MemoryStorageTier({ maxSizeBytes: 1024 * 1024 }); 561 const warm = new DiskStorageTier({ directory: `${testDir}/warm` }); 562 const cold = new DiskStorageTier({ directory: `${testDir}/cold` }); 563 564 const storage = new TieredStorage({ 565 tiers: { hot, warm, cold }, 566 placementRules: [ 567 { pattern: 'specific-pattern-only', tiers: ['warm', 'cold'] }, 568 ], 569 }); 570 571 // This doesn't match any rule 572 await storage.set('other-key', { data: 'test' }); 573 574 expect(await hot.exists('other-key')).toBe(true); 575 expect(await warm.exists('other-key')).toBe(true); 576 expect(await cold.exists('other-key')).toBe(true); 577 }); 578 }); 579});