next.js/test/unit/image-optimizer/lru-disk-eviction.test.ts
lru-disk-eviction.test.ts168 lines5.4 KB
/* eslint-env jest */
import { join } from 'path'
import { promises } from 'fs'
import { tmpdir } from 'os'
import { setTimeout } from 'timers/promises'
import {
  getOrInitDiskLRU,
  resetDiskLRU,
} from 'next/dist/server/lib/disk-lru-cache.external'

async function writeEntry(
  cacheDir: string,
  key: string,
  sizeInBytes: number,
  expireAt: number = Date.now() + 60_000
) {
  const dir = join(cacheDir, key)
  const buffer = Buffer.alloc(sizeInBytes, 0x42) // Fill with dummy data
  await promises.mkdir(dir, { recursive: true })
  await promises.writeFile(join(dir, `${expireAt}.bin`), buffer)
}

async function readEntry(cacheDir: string, key: string) {
  const dir = join(cacheDir, key)
  const [file] = await promises.readdir(dir)
  const buffer = await promises.readFile(join(dir, file))
  const [expireAtStr] = file.split('.')
  return { size: buffer.byteLength, expireAt: Number(expireAtStr) }
}

async function initEntries(
  cacheDir: string
): Promise<Array<{ key: string; size: number; expireAt: number }>> {
  const keys = await promises.readdir(cacheDir).catch(() => [])
  const entries: Array<{ key: string; size: number; expireAt: number }> = []

  for (const key of keys) {
    const { size, expireAt } = await readEntry(cacheDir, key)
    entries.push({ key, size, expireAt })
  }

  // Sort oldest-first so we can replay them chronologically into LRU
  return entries.sort((a, b) => a.expireAt - b.expireAt)
}

async function rmEntry(cacheDir: string, cacheKey: string): Promise<void> {
  await promises.rm(join(cacheDir, cacheKey), {
    recursive: true,
    force: true,
    maxRetries: 3,
    retryDelay: 500,
  })
}

describe('LRU disk eviction', () => {
  let cacheDir: string

  beforeEach(async () => {
    cacheDir = await promises.mkdtemp(join(tmpdir(), 'next-lru-test-'))
    resetDiskLRU()
  })

  afterEach(async () => {
    resetDiskLRU()
    await promises.rm(cacheDir, {
      recursive: true,
      force: true,
      maxRetries: 3,
      retryDelay: 500,
    })
  })

  it('should evict oldest entries on initialization', async () => {
    const expireAt = Date.now() + 60_000
    // Write 4 entries of 400 bytes each (total 1600)
    await writeEntry(cacheDir, 'entry-a', 400, expireAt + 1)
    await writeEntry(cacheDir, 'entry-b', 400, expireAt + 2)
    await writeEntry(cacheDir, 'entry-c', 400, expireAt + 3)
    await writeEntry(cacheDir, 'entry-d', 400, expireAt + 4)

    // Init LRU with 1500 byte limit (less than 1600 current total)
    const lru = await getOrInitDiskLRU(cacheDir, 1500, initEntries, rmEntry)

    // entry-a should have been evicted (oldest)
    expect(lru.has('entry-a')).toBe(false)
    expect(lru.has('entry-b')).toBe(true)
    expect(lru.has('entry-c')).toBe(true)
    expect(lru.has('entry-d')).toBe(true)

    // Verify disk eviction (fire-and-forget, so wait a tick)
    await setTimeout(100)
    const contents = await promises.readdir(cacheDir)
    expect(contents).toEqual(['entry-b', 'entry-c', 'entry-d'])
  })

  it('should evict old entries when new entries are set', async () => {
    const lru = await getOrInitDiskLRU(cacheDir, 1000, initEntries, rmEntry)

    // Add entries via LRU set (simulating what ImageOptimizerCache.set does)
    await writeEntry(cacheDir, 'new-a', 400)
    await writeEntry(cacheDir, 'new-b', 400)
    lru.set('new-a', 400)
    lru.set('new-b', 400)

    // Both should exist
    expect(lru.has('new-a')).toBe(true)
    expect(lru.has('new-b')).toBe(true)

    // Adding a third entry should evict the oldest (new-a)
    await writeEntry(cacheDir, 'new-c', 400)
    lru.set('new-c', 400)

    expect(lru.has('new-a')).toBe(false)
    expect(lru.has('new-b')).toBe(true)
    expect(lru.has('new-c')).toBe(true)

    // Verify disk eviction (fire-and-forget, wait a tick)
    await setTimeout(100)
    const contents = await promises.readdir(cacheDir)
    expect(contents).toEqual(['new-b', 'new-c'])
  })

  it('should promote entries on get() to prevent eviction', async () => {
    const lru = await getOrInitDiskLRU(cacheDir, 1000, initEntries, rmEntry)

    await writeEntry(cacheDir, 'x', 400)
    await writeEntry(cacheDir, 'y', 400)
    lru.set('x', 400)
    lru.set('y', 400)

    // Access 'x' to promote it (mark as recently used)
    lru.get('x')

    // Add 'z' - should evict 'y' (least recently used) instead of 'x'
    await writeEntry(cacheDir, 'z', 400)
    lru.set('z', 400)

    expect(lru.has('x')).toBe(true)
    expect(lru.has('y')).toBe(false)
    expect(lru.has('z')).toBe(true)
  })

  it('should return the same LRU instance on subsequent calls', async () => {
    const lru1 = await getOrInitDiskLRU(cacheDir, 1000, initEntries, rmEntry)
    const lru2 = await getOrInitDiskLRU(cacheDir, 1000, initEntries, rmEntry)
    expect(lru1 === lru2).toBeTrue()
  })

  it('should deduplicate concurrent init calls', async () => {
    const [lru1, lru2] = await Promise.all([
      getOrInitDiskLRU(cacheDir, 1000, initEntries, rmEntry),
      getOrInitDiskLRU(cacheDir, 1000, initEntries, rmEntry),
    ])
    expect(lru1 === lru2).toBeTrue()
  })

  it('should handle empty cache directory', async () => {
    const lru = await getOrInitDiskLRU(cacheDir, 1000, initEntries, rmEntry)
    expect(lru.size).toBe(0)
  })

  it('should handle non-existent cache directory', async () => {
    const missing = join(cacheDir, 'this-does-not-exist')
    const lru = await getOrInitDiskLRU(missing, 1000, initEntries, rmEntry)
    expect(lru.size).toBe(0)
  })
})
Quest for Codev2.0.0
/
SIGN IN