mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +03:00
Merged via /merge-batch (lote 2026-08-26, v3.8.51). Boarded no worktree combinado junto com outras ~30 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e ~370 testes focados (unit + vitest) passando. Obrigado pela contribuição.
This commit is contained in:
1
changelog.d/fixes/11573-semantic-cache-ttl.md
Normal file
1
changelog.d/fixes/11573-semantic-cache-ttl.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(cache):** a semantic-cache entry now expires on its TTL instead of surviving until the next UTC midnight, and `dbEntries` stops counting expired rows ([#11573](https://github.com/diegosouzapw/OmniRoute/pull/11573))
|
||||
@@ -31,6 +31,20 @@ function toNumber(value: unknown, fallback = 0): number {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current time in the same format `expires_at` and `created_at` are written in.
|
||||
*
|
||||
* `expires_at` is a TEXT column holding an ISO-8601 string ("2026-08-26T14:00:00.000Z"),
|
||||
* so it has to be compared against another ISO-8601 string. Comparing it to SQLite's
|
||||
* `datetime('now')` ("2026-08-26 14:00:00") is a lexicographic comparison that diverges
|
||||
* at index 10, where the stored value has 'T' (0x54) and `datetime('now')` has ' ' (0x20).
|
||||
* 'T' sorts after ' ', so every row whose UTC calendar date was today compared as
|
||||
* unexpired regardless of its real time-of-day TTL.
|
||||
*/
|
||||
function isoNow(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function ensureCacheMetricsTable() {
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
@@ -203,9 +217,9 @@ export function getCachedResponse(signature) {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT response, tokens_saved FROM semantic_cache WHERE signature = ? AND expires_at > datetime('now')"
|
||||
"SELECT response, tokens_saved FROM semantic_cache WHERE signature = ? AND expires_at > ?"
|
||||
)
|
||||
.get(signature);
|
||||
.get(signature, isoNow());
|
||||
|
||||
if (row) {
|
||||
const record = asRecord(row);
|
||||
@@ -342,8 +356,8 @@ export function getCacheStats() {
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT COUNT(*) as count FROM semantic_cache WHERE expires_at > datetime('now')")
|
||||
.get();
|
||||
.prepare("SELECT COUNT(*) as count FROM semantic_cache WHERE expires_at > ?")
|
||||
.get(isoNow());
|
||||
dbSize = toNumber(asRecord(row).count, 0);
|
||||
} catch {
|
||||
// DB not available
|
||||
|
||||
116
tests/unit/11559-semantic-cache-ttl-expiry.test.ts
Normal file
116
tests/unit/11559-semantic-cache-ttl-expiry.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* #11559 — the semantic cache never expired an entry whose `expires_at` fell on the
|
||||
* current UTC calendar day.
|
||||
*
|
||||
* `expires_at` is written as an ISO-8601 string ("2026-08-26T14:00:00.000Z") but was read
|
||||
* back with `expires_at > datetime('now')`, and `datetime('now')` renders as
|
||||
* "2026-08-26 14:00:00". Both are TEXT, so SQLite compares them lexicographically and they
|
||||
* diverge at index 10, where the stored value has 'T' (0x54) and `datetime('now')` has
|
||||
* ' ' (0x20). 'T' sorts after ' ', so any row whose date part equalled today's date looked
|
||||
* unexpired no matter what its time-of-day TTL said — up to ~24h of staleness, and it
|
||||
* survived restarts because `getCachedResponse` promotes DB rows back into the LRU.
|
||||
*/
|
||||
|
||||
import { describe, it, before, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
clearCache,
|
||||
clearMemoryCache,
|
||||
getCachedResponse,
|
||||
getCacheStats,
|
||||
setCachedResponse,
|
||||
} from "../../src/lib/semanticCache.ts";
|
||||
import { getDbInstance } from "../../src/lib/db/core.ts";
|
||||
|
||||
function ensureSemanticCacheTable(db) {
|
||||
db.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS semantic_cache (
|
||||
id TEXT PRIMARY KEY,
|
||||
signature TEXT NOT NULL UNIQUE,
|
||||
model TEXT NOT NULL,
|
||||
prompt_hash TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
tokens_saved INTEGER DEFAULT 0,
|
||||
hit_count INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
)`
|
||||
).run();
|
||||
}
|
||||
|
||||
/** Midnight of the UTC day SQLite itself considers "today" — always <= `datetime('now')`. */
|
||||
function startOfSqliteToday(db): string {
|
||||
const row = db.prepare("SELECT datetime('now') AS now").get();
|
||||
return `${String(row.now).slice(0, 10)}T00:00:00.000Z`;
|
||||
}
|
||||
|
||||
function insertRow(db, signature: string, expiresAt: string, response: unknown) {
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO semantic_cache
|
||||
(id, signature, model, prompt_hash, response, tokens_saved, hit_count, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)`
|
||||
).run(
|
||||
`id-${signature}`,
|
||||
signature,
|
||||
"gpt-4o",
|
||||
signature.slice(0, 16),
|
||||
JSON.stringify(response),
|
||||
7,
|
||||
new Date(Date.now() - 60_000).toISOString(),
|
||||
expiresAt
|
||||
);
|
||||
}
|
||||
|
||||
describe("#11559 semantic cache TTL expiry", () => {
|
||||
let db;
|
||||
|
||||
before(() => {
|
||||
db = getDbInstance();
|
||||
ensureSemanticCacheTable(db);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
it("misses a row that expired earlier today (UTC)", () => {
|
||||
// Expired at 00:00 UTC today: same calendar date as `datetime('now')`, so the old
|
||||
// `expires_at > datetime('now')` predicate answered true on the 'T' vs ' ' byte alone.
|
||||
insertRow(db, "sig-expired-today", startOfSqliteToday(db), { choices: ["stale"] });
|
||||
clearMemoryCache();
|
||||
|
||||
assert.equal(
|
||||
getCachedResponse("sig-expired-today"),
|
||||
null,
|
||||
"an entry whose TTL lapsed earlier today must not be served"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not count a row that expired earlier today in dbEntries", () => {
|
||||
insertRow(db, "sig-stats-expired", startOfSqliteToday(db), { choices: ["stale"] });
|
||||
clearMemoryCache();
|
||||
|
||||
assert.equal(getCacheStats().dbEntries, 0);
|
||||
});
|
||||
|
||||
it("still serves a row whose TTL has not lapsed", () => {
|
||||
const response = { choices: [{ message: { content: "fresh" } }] };
|
||||
insertRow(db, "sig-live", new Date(Date.now() + 3_600_000).toISOString(), response);
|
||||
clearMemoryCache();
|
||||
|
||||
assert.deepEqual(getCachedResponse("sig-live"), response);
|
||||
assert.equal(getCacheStats().dbEntries, 1);
|
||||
});
|
||||
|
||||
it("survives a memory eviction on a normally written entry", () => {
|
||||
const response = { choices: [{ message: { content: "written" } }] };
|
||||
setCachedResponse("sig-roundtrip", "gpt-4o", response, 12, 3_600_000);
|
||||
clearMemoryCache();
|
||||
|
||||
assert.deepEqual(
|
||||
getCachedResponse("sig-roundtrip"),
|
||||
response,
|
||||
"the read predicate must still match the ISO format setCachedResponse writes"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user