From b06bbdfb20ad815acafbc03860699807f1052408 Mon Sep 17 00:00:00 2001 From: Simon Relet Date: Mon, 6 Apr 2026 22:08:26 +0200 Subject: [PATCH] feat(admin): Add A/B test on animal fuzzy search The goal is to check if there are performances issues with it --- apps/admin/.env | 1 + apps/admin/src/animals/db.server.ts | 171 ++++++++++++++++++++++++++-- apps/admin/src/core/env.server.ts | 3 + 3 files changed, 164 insertions(+), 11 deletions(-) diff --git a/apps/admin/.env b/apps/admin/.env index 1e5875bf..b2aa94ee 100644 --- a/apps/admin/.env +++ b/apps/admin/.env @@ -32,6 +32,7 @@ SHOW_NOTIFICATION_ENDPOINT=http://localhost:3001/api/notification # -- Feature flags ------------------------------------------------------------- ENABLE_CRONS=false +FEATURE_FLAG_ANIMAL_TEXT_SEARCH_MODE=fuzzy PRICE_ADDITIONAL_BRACELET=2 PRICE_BREAKFAST_PER_PERSON_PER_DAY=3 diff --git a/apps/admin/src/animals/db.server.ts b/apps/admin/src/animals/db.server.ts index e9d9147f..2763a0b7 100644 --- a/apps/admin/src/animals/db.server.ts +++ b/apps/admin/src/animals/db.server.ts @@ -26,6 +26,9 @@ import { NotFoundError, PrismaErrorCodes } from "#i/core/errors.server.js" import { orderByRank } from "#i/core/order-by-rank.js" import { prisma } from "#i/core/prisma.server.js" +const ANIMAL_TEXT_SEARCH_MODE = + process.env.FEATURE_FLAG_ANIMAL_TEXT_SEARCH_MODE ?? "fuzzy" + export class AnimalDbDelegate { readonly picture = new AnimalPictureDbDelegate() readonly profile = new AnimalProfileDbDelegate() @@ -120,21 +123,114 @@ export class AnimalDbDelegate { }) } - const hits = await this.getHits(nameOrAlias) + const mode = ANIMAL_TEXT_SEARCH_MODE + + switch (mode) { + // Mode: off - skip text search entirely. + case "off": { + // Measured to get reference values. + const [animals, animalsMetrics] = await this.measureTimeAndMemory(() => + prisma.animal.findMany({ + where, + select: { ...select, ...internalSelect }, + orderBy: [ + { name: "asc" }, + { alias: "asc" }, + { pickUpDate: "desc" }, + ], + take, + }), + ) + + console.log( + `[ANIMAL_SEARCH] fn=getAnimals mode=off elapsedMs=${animalsMetrics.elapsedMs} heapUsedDeltaKb=${animalsMetrics.heapUsedDeltaKb}KB results=${animals.length}`, + ) + + return animals + } + + case "like": { + const hits = await this.getHits(nameOrAlias) + + const [animals, animalsMetrics] = await this.measureTimeAndMemory( + async () => { + return (await prisma.animal.findMany({ + where: { ...where, id: { in: hits.map((hit) => hit.id) } }, + select: { ...select, ...internalSelect }, + take, + })) as Prisma.AnimalGetPayload<{ select: typeof internalSelect }>[] + }, + ) + + console.log( + `[ANIMAL_SEARCH] fn=getAnimals mode=like elapsedMs=${animalsMetrics.elapsedMs} heapUsedDeltaKb=${animalsMetrics.heapUsedDeltaKb}KB results=${animals.length}`, + ) + + return animals as Prisma.AnimalGetPayload<{ + select: typeof select & typeof internalSelect + }>[] + } + + case "fuzzy": { + const hits = await this.getHits(nameOrAlias) - const animals = (await prisma.animal.findMany({ - where: { ...where, id: { in: hits.map((hit) => hit.id) } }, - select: { ...select, ...internalSelect }, - })) as Prisma.AnimalGetPayload<{ select: typeof internalSelect }>[] + const [animals, animalsMetrics] = await this.measureTimeAndMemory( + async () => { + const animals = (await prisma.animal.findMany({ + where: { ...where, id: { in: hits.map((hit) => hit.id) } }, + select: { ...select, ...internalSelect }, + })) as Prisma.AnimalGetPayload<{ select: typeof internalSelect }>[] - return orderByRank(animals, hits, { take }) as Prisma.AnimalGetPayload<{ - select: typeof select & typeof internalSelect - }>[] + return orderByRank(animals, hits, { take }) + }, + ) + + console.log( + `[ANIMAL_SEARCH] fn=getAnimals mode=fuzzy elapsedMs=${animalsMetrics.elapsedMs} heapUsedDeltaKb=${animalsMetrics.heapUsedDeltaKb}KB results=${animals.length}`, + ) + + return animals as Prisma.AnimalGetPayload<{ + select: typeof select & typeof internalSelect + }>[] + } + + default: { + return mode satisfies never + } + } } - private async getHits( + private async getHits(nameOrAlias: string): Promise { + if (ANIMAL_TEXT_SEARCH_MODE === "off") { + return [] + } + + const [hits, hitsMetrics] = await this.measureTimeAndMemory(async () => { + switch (ANIMAL_TEXT_SEARCH_MODE) { + case "like": { + return await this.getHitsLike(nameOrAlias) + } + + case "fuzzy": { + return await this.getHitsFuzzy(nameOrAlias) + } + + default: { + return ANIMAL_TEXT_SEARCH_MODE satisfies never + } + } + }) + + console.log( + `[ANIMAL_SEARCH] fn=getHits mode=${ANIMAL_TEXT_SEARCH_MODE} elapsedMs=${hitsMetrics.elapsedMs} heapUsedDeltaKb=${hitsMetrics.heapUsedDeltaKb}KB hits=${hits.length}`, + ) + + return hits + } + + private async getHitsFuzzy( nameOrAlias: string, - ): Promise<{ id: string; matchRank: number }[]> { + ): Promise { return await prisma.$queryRaw` WITH ranked_animals AS ( @@ -155,6 +251,29 @@ export class AnimalDbDelegate { ` } + /** + * Simple LIKE-based search for comparison against fuzzy matching. + * Uses case-insensitive ILIKE, searches name and alias. + */ + private async getHitsLike( + nameOrAlias: string, + ): Promise { + const searchTerm = `%${nameOrAlias}%` + + return await prisma.$queryRaw` + SELECT + id + FROM + "Animal" + WHERE + LOWER(name) LIKE LOWER(${searchTerm}) + OR LOWER(alias) LIKE LOWER(${searchTerm}) + ORDER BY + name ASC, + alias ASC + ` + } + async createFindManyParams( searchParams: SearchParamsIO.Infer, sort: AnimalSort, @@ -257,7 +376,9 @@ export class AnimalDbDelegate { if (searchParams.nameOrAlias != null) { const hits = await this.getHits(searchParams.nameOrAlias) - where.push({ id: { in: hits.map((hit) => hit.id) } }) + if (hits.length > 0) { + where.push({ id: { in: hits.map((hit) => hit.id) } }) + } } if (searchParams.identification.size > 0) { @@ -368,6 +489,34 @@ export class AnimalDbDelegate { where: { AND: where }, } satisfies Prisma.AnimalFindManyArgs } + + private async measureTimeAndMemory( + operation: () => Promise, + ) { + const memBefore = process.memoryUsage() + const startTime = Date.now() + const result = await operation() + const elapsedMs = Date.now() - startTime + const memAfter = process.memoryUsage() + const heapUsedDeltaKb = Math.round( + (memAfter.heapUsed - memBefore.heapUsed) / 1024, + ) + + return [ + result, + { + elapsedMs, + heapUsedDeltaKb, + }, + ] as const + } +} + +export namespace AnimalDbDelegate { + export type Hit = { + id: string + matchRank?: number + } } export class PickUpLocationDbDelegate { diff --git a/apps/admin/src/core/env.server.ts b/apps/admin/src/core/env.server.ts index 42d75101..ad9bbd3a 100644 --- a/apps/admin/src/core/env.server.ts +++ b/apps/admin/src/core/env.server.ts @@ -76,6 +76,9 @@ const processEnvSchema = zu CLOUDINARY_CLOUD_NAME: zu.string(), DATABASE_URL: zu.string(), ENABLE_CRONS: zu.enum(["true", "false"]), + FEATURE_FLAG_ANIMAL_TEXT_SEARCH_MODE: zu + .enum(["fuzzy", "like", "off"]) + .optional(), GOOGLE_API_CLIENT_EMAIL: zu.string().min(1).optional(), GOOGLE_API_PRIVATE_KEY: zu.string().min(1).optional(), GOOGLE_DRIVE_ROOT_FOLDER_ID: zu.string(),