Skip to content

perf(db): optimize Turso reads/writes — batched scan upsert, indexes, client cache - #27

Draft
RenJeka wants to merge 17 commits into
mainfrom
claude/turso-db-optimization-dv0q4p
Draft

RenJeka wants to merge 17 commits into
mainfrom
claude/turso-db-optimization-dv0q4p

Conversation

@RenJeka

@RenJeka RenJeka commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Контекст

Застосунок працює на Turso (@libsql/client), де кожен execute — окремий мережевий round-trip, а операції рядків тарифікуються. Код у стилі «синхронний SQLite» (запит-на-рядок у циклі) перетворював один скан на сотні-тисячі мережевих викликів. Користувач помітив надлишкові запити й «дублювання (читання → перезаписування)».

Раунд 1 — оптимізація скану + клієнтський кеш

1. Згортання upsert-циклу скану (головний виграш)server/src/scraper/normalizer.ts
Замість циклу на кожне оголошення EXISTS → upsert → read-back → UPDATE filtered_out (~4 round-trip/рядок усередині довгої інтерактивної транзакції):

  • один bulk-SELECT наявних полів по olx_id (чанками по 500);
  • new_count, злиття COALESCE-значень і filtered_out рахуються в пам'яті;
  • filtered_out вписано в сам UPSERT (без окремого read-back/UPDATE);
  • усі upsert-и — одним db.batch('write') (атомарно, як транзакція).

Скан на N оголошень: ~2 round-trip замість ~4N. Семантика збережена 1:1.

2. Індексиserver/src/db/schema.sql: idx_listings_search_refresh (search_id, last_refresh_at), idx_listings_search_lastseen (search_id, last_seen_at).

3. Тротлінг прогресу скануserver/src/scanner/scanRunLifecycle.ts: onProgress писав scan_runs на КОЖЕН запит; додано тротл ≥1с.

4. Тюнінг TanStack Queryweb/src/main.tsx: staleTime: 60_000 + refetchOnWindowFocus: false.

Раунд 2 — зайві читання при звичайному перезавантаженні сторінки

Reload з ~400 оголошеннями давав ~2600 читань Turso (≈6.5 на рядок). Причина — кілька повних проходів по рядках пошуку за один reload.

5. /stats — 4 проходи → 1server/src/routes/searches.ts
Замість 4 окремих сканів (in_db COUNT, stale COUNT, verify P1 COUNT, verify P2 COUNT) — один агрегат через SUM(CASE…). P1_CONDITION/P2_CONDITION тепер експортуються з verifyScan.ts і реюзаться (єдине джерело предикату). ~1600 → ~400 читань.

6. /filter-options — 4 проходи → 1server/src/routes/searches.ts
Замість 4 сканів (DISTINCT city, DISTINCT seller, pros, cons) — один SELECT і дедуп у JS. categories лишається з category_facet.

Очікуваний reload: ~2600 → ~850 читань. Контракти SearchStats/FilterOptions незмінні — фронт не чіпали.

Перевірка

  • npm run build (server + web, strict TS) — зелено.
  • Раунд 1: на тимчасовій file: БД індекси у EXPLAIN QUERY PLAN; new_count/filtered_out/дедуп/olx_status-disable збігаються з попередньою поведінкою.
  • Раунд 2: на тих самих даних новий агрегат /stats і консолідований /filter-options дають ідентичні числа/списки, що й старі окремі запити (in_db/stale/verify; cities/sellers).

Поза скоупом (свідомо): Turso embedded replica, persist кешу запитів у localStorage, серверна пагінація списку (підлога ~400 читань = сам список).

🤖 Generated with Claude Code

https://claude.ai/code/session_01UHUDe59mPDD1miT5km3n3u

RenJeka and others added 8 commits June 23, 2026 16:03
…Turso)

Phase 0: swap the DB access mechanism only — business logic, schema, and the
OLX collection method are unchanged. libSQL is SQLite-compatible (single code
path: file: locally, Turso URL in prod), enabling Render + Turso deploy.

- db.ts: createClient + initDb (executeMultiple schema.sql) + thin async
  helpers dbGet/dbAll/dbRun; drop the historical migration scaffold
  (addColumnIfMissing/migrateListingsTable/backfill/PRAGMA/WAL) — schema.sql
  already holds every column.
- env.ts: load server/.env before db.ts reads TURSO_* (imported first in db.ts).
- Whole DB layer is async: every .get/.all/.run -> await dbGet/dbAll/dbRun;
  interactive db.transaction('write') for read->decide->write (upsert,
  statusEngine, analysis/relevance/aiPicks commit); db.batch('write') for pure
  write sets (cascade delete, sort swaps, filtered_out recompute, migratePostedAt).
- index.ts: host 0.0.0.0, CORS origin from WEB_ORIGIN, await initDb before listen.
- CLI entrypoints (scan.ts, migratePostedAt.ts) call initDb.
- .env.example: TURSO_DATABASE_URL/TURSO_AUTH_TOKEN/WEB_ORIGIN.
- docs: architecture.md/structure.md updated; render-turso-phase0.md marked done.

Verified locally against libSQL file:: build green; /health ok; live OLX scan
(GraphQL) -> upsert+dedup; coverage-window disable; manual PATCH override;
CLI scan; cascade-delete/recompute via batch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AX5fZLCKjxyASaMAxMxxum
Beginner-friendly walkthrough: Turso DB setup, Render Web Service (backend) with
build/start commands and env vars, Render Static Site (frontend) with /api/*
rewrite (same-origin, no CORS), verification, free-tier cold-start notes, and
troubleshooting. No code changes — frontend keeps relative /api fetch; the
Render rewrite proxies it to the backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AX5fZLCKjxyASaMAxMxxum
Gate access to the dashboard behind Google login (allowlist of one email)
without turning the app multi-user — no users table, no user_id, no data
partitioning. Auth is a lock on the front door plus a session cookie.

Server (server/src/auth/):
- config.ts: env (GOOGLE_CLIENT_ID/ALLOWED_EMAILS/SESSION_SECRET), cookie
  flags (prod cross-site Secure+SameSite=None, local http Lax), fail-fast
  assertAuthConfigured, AUTH_DISABLED dev bypass.
- plugin.ts: fastify-plugin (non-encapsulated) registering @fastify/cookie
  and @fastify/jwt, verifyGoogleIdToken via google-auth-library, global
  onRequest lock on /api/* (skips /health, /api/auth/*, CORS preflight).
- routes.ts: POST /api/auth/google, GET /api/auth/me, POST /api/auth/logout.
- index.ts: import ./env first, assertAuthConfigured, CORS credentials,
  register auth before domain routes.

Frontend (web/src/auth/):
- useAuth.ts: useSession/useLogin/useLogout, reacts to global 401 event.
- AuthGate.tsx: login screen with GoogleLogin, wraps the app.
- base.ts: credentials: 'include', VITE_API_BASE prefix, 401 -> gate event.
- main.tsx: GoogleOAuthProvider. Header: logout button.

DB schema untouched. Docs + .env examples updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZmcZ6mmoQa5rv9gCa1KX
… cache tuning

Optimize database reads/writes for the Turso (libSQL) backend, where every
execute is a network round-trip.

- normalizer.upsertListings: replace the per-listing loop (EXISTS -> upsert ->
  read-back -> UPDATE filtered_out, ~4 round-trips/row) with one bulk SELECT of
  existing fields, in-memory COALESCE merge + filtered_out, and a single
  db.batch('write'). A scan of N listings now does ~2 round-trips instead of ~4N.
  filtered_out is folded into the UPSERT (no separate read/write).
- schema: add idx_listings_search_refresh (coverage window) and
  idx_listings_search_lastseen (verify P1).
- scanRunLifecycle: throttle scan-progress writes to >=1s (poll is 1.5s),
  avoiding dozens of redundant UPDATEs during deep scans.
- web: QueryClient defaults staleTime 60s + refetchOnWindowFocus false to stop
  refetching the full listings list on every window focus.
- docs/architecture.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHUDe59mPDD1miT5km3n3u
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f072472-dea5-44cf-bc83-082422aa3729

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/turso-db-optimization-dv0q4p

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

RenJeka and others added 9 commits June 26, 2026 13:02
strict mode bug
  Render cannot reach www.googleapis.com cert endpoints (v1 PEM → 403,
  v3 JWK → non-200), so google-auth-library/jose-JWKS verification fails
  in production. Verify the ID token primarily via the tokeninfo endpoint
  on oauth2.googleapis.com (a reachable host; Google checks the signature,
  we validate aud/iss/email_verified), falling back to local jose+JWKS
  verification where www.googleapis.com is reachable (e.g. local dev).

  Якщо на Render увімкнено auto-deploy з гілки — після пушу він сам передеплоїться. Підтверди — і я запушу (або скажи, чи хочеш разом із рештою змін цієї сесії:
  оптимізація БД + фронтенд-фікси).
…sses

On a plain page reload these always-fetched (stats) / on-demand (filter-options)
endpoints scanned the search's listings multiple times. On Turso each pass bills
N rows read, so for ~400 listings a reload cost ~2600 reads.

- /stats: replace 4 separate scans (in_db COUNT, stale COUNT, verify P1 COUNT,
  verify P2 COUNT) with one aggregate using conditional SUM(CASE...). Reuse
  P1_CONDITION/P2_CONDITION (now exported from verifyScan.ts) so the verify
  predicate stays single-sourced. ~1600 -> ~400 reads.
- /filter-options: replace 4 scans (DISTINCT city, DISTINCT seller_name, pros,
  cons) with one SELECT and JS dedup. categories still from category_facet.
- SearchStats / FilterOptions contracts unchanged; frontend untouched.

Verified on a temp DB: aggregate stats and consolidated filter lists match the
old per-query results exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHUDe59mPDD1miT5km3n3u
…sses

On a plain page reload these always-fetched (stats) / on-demand (filter-options)
endpoints scanned the search's listings multiple times. On Turso each pass bills
N rows read, so for ~400 listings a reload cost ~2600 reads.

- /stats: replace 4 separate scans (in_db COUNT, stale COUNT, verify P1 COUNT,
  verify P2 COUNT) with one aggregate using conditional SUM(CASE...). Reuse
  P1_CONDITION/P2_CONDITION (now exported from verifyScan.ts) so the verify
  predicate stays single-sourced. ~1600 -> ~400 reads.
- /filter-options: replace 4 scans (DISTINCT city, DISTINCT seller_name, pros,
  cons) with one SELECT and JS dedup. categories still from category_facet.
- SearchStats / FilterOptions contracts unchanged; frontend untouched.

Verified on a temp DB: aggregate stats and consolidated filter lists match the
old per-query results exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHUDe59mPDD1miT5km3n3u
…q4p' into claude/turso-db-optimization-dv0q4p
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants