feat: replace Redis with DragonflyDB
CI / check (push) Successful in 31s
CI / release (push) Skipped
CI / deploy (push) Successful in 57s

- Replace Redis server with DragonflyDB v1.40.1 (Redis protocol compatible)
- Stop redis-server service, enable dragonfly service on 127.0.0.1:6379
- Configure dragonfly in /etc/dragonfly/dragonfly.conf (bind 127.0.0.1, maxmemory 2gb)
- Update .env: remove REDIS_URL reference

Improve database reliability:
- Fix catalog-tree.ts: remove CAST(page_id AS CHAR) to enable index usage (122 rows vs 78k full scan)
- Fix catalog-repair.ts: replace sql.raw() string interpolation with parameterized sql queries using quoteIdentifier()
- Improve redis retry resilience: change retryStrategy to not give up after 3 attempts, enabling automatic reconnect after server restart

Update documentation:
- Update README: replace Redis references with DragonflyDB, add DragonflyDB setup section, update performance features list, update architecture diagram
- biome and typecheck pass clean
This commit is contained in:
openhands committed 2026-08-12 14:34:29 +02:00
1 parent 9463c8d4da
commit 65e3915a5f
4 files changed
+89 -28

No files matched your search

+1 -2
View File
@@ -24,8 +24,7 @@ function createRedis(): Redis | null {
const client = new Redis(url, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
if (times > 3) return null;
return Math.min(times * 200, 2000);
return Math.min(times * 200, 5000);
},
lazyConnect: true,
});
+21 -16
View File
@@ -30,6 +30,13 @@ function escSqlLiteral(value: string): string {
return value.replace(/'/g, "''");
}
const VALID_IDENTIFIER = /^[a-zA-Z0-9_]+$/;
function quoteIdentifier(identifier: string): string {
if (!VALID_IDENTIFIER.test(identifier)) throw new Error("Invalid identifier");
return `\`${identifier}\``;
}
/**
* Mapping of event prefix substrings (lowercase, no leading underscore) to
* their human-readable English page labels. Covers every seasonal/holiday/event
@@ -524,7 +531,7 @@ export async function repairDuplicateClassnames(): Promise<DuplicateClassnameRes
];
for (const { table, idCol } of listTables) {
const [rows] = (await db.execute(
sql.raw(`SELECT ${idCol} AS pk, item_ids AS v FROM ${table}`),
sql`SELECT ${sql.raw(quoteIdentifier(idCol))} AS pk, item_ids AS v FROM ${sql.raw(quoteIdentifier(table))}`,
)) as unknown as [Array<{ pk: number; v: string | null }>, unknown];
for (const row of rows) {
if (!row.v) continue;
@@ -545,9 +552,7 @@ export async function repairDuplicateClassnames(): Promise<DuplicateClassnameRes
}
if (!changed) continue;
await db.execute(
sql.raw(
`UPDATE ${table} SET item_ids = '${out.join(";")}' WHERE ${idCol} = ${row.pk}`,
),
sql`UPDATE ${sql.raw(quoteIdentifier(table))} SET item_ids = ${out.join(";")} WHERE ${sql.raw(quoteIdentifier(idCol))} = ${row.pk}`,
);
remapped++;
}
@@ -587,16 +592,16 @@ export async function repairDuplicateClassnames(): Promise<DuplicateClassnameRes
for (const table of singleTables) {
for (let i = 0; i < entries.length; i += CHUNK) {
const chunk = entries.slice(i, i + CHUNK);
const cases = chunk
.map(([dup, canonical]) => `WHEN ${dup} THEN ${canonical}`)
.join(" ");
const dupList = chunk.map(([dup]) => `${dup}`).join(",");
const cases = chunk.map(
([dup, canonical]) => sql`WHEN ${dup} THEN ${canonical}`,
);
const dupList = chunk.map(([dup]) => dup);
try {
const [result] = (await db.execute(
sql.raw(
`UPDATE ${table} SET item_id = CASE item_id ${cases} ELSE item_id END WHERE item_id IN (${dupList})`,
),
)) as unknown as [Record<string, unknown>, unknown];
const [result] = (await db.execute(sql`
UPDATE ${sql.raw(quoteIdentifier(table))}
SET item_id = CASE item_id ${sql.join(cases, sql` `)} ELSE item_id END
WHERE item_id IN (${sql.join(dupList, sql`, `)})
`)) as unknown as [Record<string, unknown>, unknown];
remapped += Number(result.affectedRows ?? 0);
} catch {
// Table may not exist on some hotel schemas — skip it.
@@ -605,9 +610,9 @@ export async function repairDuplicateClassnames(): Promise<DuplicateClassnameRes
}
const dupIds = entries.map(([dup]) => dup);
await db.execute(
sql.raw(`DELETE FROM items_base WHERE id IN (${dupIds.join(",")})`),
);
await db.execute(sql`
DELETE FROM items_base WHERE id IN (${sql.join(dupIds, sql`, `)})
`);
return { merged, rowsRemoved: dupIds.length, remapped };
}
+7 -4
View File
@@ -12,7 +12,9 @@ function toInt(value: unknown, fallback = 0): number {
/**
* Count catalog_items per page via raw SQL.
* Real Habbo DBs often store page_id as VARCHAR; numeric groupBy fails or returns 0.
* Real Habbo DBs often store page_id as VARCHAR. String literals match both
* INT and VARCHAR columns while keeping the page_id index usable (CAST(... AS CHAR)
* would force a full index scan).
*/
export async function getCatalogItemCounts(
pageIds?: number[],
@@ -26,7 +28,7 @@ export async function getCatalogItemCounts(
const [rows] = (await db.execute(sql`
SELECT page_id, COUNT(*) as cnt
FROM catalog_items
WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)})
WHERE page_id IN (${sql.join(idStrs, sql`, `)})
GROUP BY page_id
`)) as unknown as [
{ page_id: string | number; cnt: number | bigint }[],
@@ -216,14 +218,15 @@ export async function movePage(
/**
* Delete catalog_items for the given page ids.
* Habbo DBs often store page_id as VARCHAR; typed Int deletes can miss rows.
* Habbo DBs often store page_id as VARCHAR; text delete on INT or VARCHAR
* columns keeps the page_id index usable (CAST would force a full scan).
*/
async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise<void> {
if (pageIds.length === 0) return;
const idStrs = pageIds.map(String);
await db.execute(sql`
DELETE FROM catalog_items
WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)})
WHERE page_id IN (${sql.join(idStrs, sql`, `)})
`);
}