Skip to content

SqliteCacheStore returns a cache miss when an earlier expired Vary variant precedes a valid one #5094

@trivikr

Description

@trivikr

Bug Description

SqliteCacheStore returns early when the first cached row for a URL/method is expired

Reproducible By

import { strictEqual } from "node:assert";
import { once } from "node:events";
import { createServer } from "node:http";
import { setTimeout as sleep } from "node:timers/promises";
import {
  Agent,
  request,
  setGlobalDispatcher,
  getGlobalDispatcher,
  interceptors,
  cacheStores,
} from "undici";

if (!cacheStores.SqliteCacheStore) {
  throw new Error("SqliteCacheStore is not available in this Node.js build");
}

let requestCount = 0;
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
  requestCount++;

  const ae = req.headers["accept-encoding"];
  res.setHeader("Vary", "Accept-Encoding");

  if (ae === "br") {
    // Keep the br variant valid so it should still be returned from cache.
    res.setHeader("Cache-Control", "max-age=60");
    res.end(`br from origin #${requestCount}`);
    return;
  }

  if (ae === "gzip") {
    // Make the gzip variant expire sooner than the br variant.
    res.setHeader("Cache-Control", "max-age=1");
    res.end(`gzip from origin #${requestCount}`);
    return;
  }

  res.statusCode = 400;
  res.end(`unexpected accept-encoding: ${ae}`);
});

await once(server.listen(0), "listening");

const origin = `http://localhost:${server.address().port}`;
const previousDispatcher = getGlobalDispatcher();
const dispatcher = new Agent().compose(
  interceptors.cache({
    store: new cacheStores.SqliteCacheStore(),
    methods: ["GET"],
  }),
);

setGlobalDispatcher(dispatcher);

try {
  {
    const res = await request(origin, {
      method: "GET",
      headers: { "accept-encoding": "br" },
    });
    const body = await res.body.text();
    strictEqual(body, "br from origin #1");
    strictEqual(requestCount, 1);
  }

  {
    const res = await request(origin, {
      method: "GET",
      headers: { "accept-encoding": "gzip" },
    });
    const body = await res.body.text();
    strictEqual(body, "gzip from origin #2");
    strictEqual(requestCount, 2);
  }

  // SqliteCacheStore orders candidates by deleteAt ASC, so wait until the gzip variant is actually expired.
  await sleep(2500);

  {
    const res = await request(origin, {
      method: "GET",
      headers: { "accept-encoding": "br" },
    });
    const body = await res.body.text();

    console.log({ body, requestCount });
  }
} finally {
  setGlobalDispatcher(previousDispatcher);
  await dispatcher.close();
  await new Promise((resolve) => server.close(resolve));
}

Expected Behavior

{ body: 'br from origin #1', requestCount: 2 }

SqliteCacheStore#get() should ignore expired rows while scanning candidate entries and continue searching for another matching row for the same URL/method. If a later Vary variant is still valid and matches the request headers, it should be returned.

Logs & Screenshots

{ body: 'br from origin #3', requestCount: 3 }

Environment

macOS 26.4.1
Node v24.15.0
undici v8.1.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions