Skip to content

Latest commit

 

History

History
80 lines (57 loc) · 5.19 KB

File metadata and controls

80 lines (57 loc) · 5.19 KB

DECISIONS

1. Stack — one concrete reason each (specific to this task)

Frontend — React + TypeScript (Vite)

  • Reason: The dashboard must keep filters, sort, and page_size in the URL while the table loads many rows incrementally. React Router’s useSearchParams gives shareable/bookmarkable state without custom history glue; pairing that with TanStack Query keeps useInfiniteQuery (server pages) and useMutation (annotation PATCH) as separate concerns. TypeScript types in web/src/api/types.ts mirror the gap shape the UI edits (annotation, id, tenant_id) so the modal and optimistic cache updates stay aligned with the contract — assessors can cross-check against /openapi.json.

Backend — Python + FastAPI

  • Reason: The brief expects discoverable HTTP docs and a clear tenant boundary. FastAPI generates OpenAPI + Swagger UI from route signatures so /docs and /openapi.json stay accurate without hand-maintained schemas; shared X-Tenant-Id dependencies apply to every gap route so new endpoints don’t accidentally skip tenant checks — important when annotation edits are PATCH /gaps/{id} scoped by tenant.

2. The ~500-row (and larger) table — approach, limits at ~50k, changes

What we shipped

  • Server-side pagination: GET /gaps uses page + page_size (capped at 1000). The UI uses useInfiniteQuery, flattens pages → items, and loads the next page when the user scrolls near the bottom — infinite scroll on top of offset pages, not one giant download.
  • Virtualisation: @tanstack/react-virtual renders only rows near the viewport (fixed row height) so the DOM stays small even when 500+ loaded rows are cached client-side; the header row stays outside the scrollport for a stable grid chrome.
  • Tests / CI: integration tests use a temp SQLite DB seeded smaller than production defaults so pytest stays fast; the proof checklist still targets 500+ visible capability via filters/page_size against the real seed.

What breaks around ~50 000 (filtered) rows

  • SQLite OFFSET: deep pages (page large) get progressively slower — scanning/skipping large offsets hurts latency.
  • Client memory: if the user keeps loading pages forever, holding every fetched row in React Query grows RAM and reconciliation cost even though virtualisation limits DOM nodes.

What we’d change first

  • API: cursor / key-set pagination (stable sort key + id) instead of large offsets; optional payload slimming (omit heavy fields from list rows).
  • DB: composite indexes aligned to filter + sort (tenant_id, filter columns, sort columns, id).
  • UI: trim or cap retained pages above the viewport if sessions routinely walk huge sets; debounce text search to avoid refetch storms.

3. Optimistic annotation — rollback on error; concurrent edits within ~1s

Rollback on error — snapshot before mutate; restore in onError (from web/src/hooks/useAnnotationMutation.ts):

    onMutate: async (vars) => {
      await queryClient.cancelQueries({ queryKey: gapsQueryKey })
      const prev = queryClient.getQueryData<InfiniteData<GapListResponse>>(gapsQueryKey)
      queryClient.setQueryData<InfiniteData<GapListResponse>>(gapsQueryKey, (old) => {
        if (!old) return old
        return {
          ...old,
          pages: old.pages.map((page) => ({
            ...page,
            items: page.items.map((g) =>
              g.id === vars.id ? { ...g, annotation: vars.annotation } : g,
            ),
          })),
        }
      })
      return { prev }
    },

    onError: (_error, _vars, context) => {
      if (context?.prev !== undefined) {
        queryClient.setQueryData(gapsQueryKey, context.prev)
      }
    },

Two users editing the same gap’s annotation within ~1 second

  • There is no optimistic locking / ETag on PATCH today: whoever’s PATCH commits last wins in the database.
  • After onSettled, invalidateQueries refetches the list; the slower client’s UI will overwrite local text with server truth, so the “loser” may see their typing disappear without a merge dialog.
  • A production fix: If-Match / version column / updated_at check on PATCH → 409 Conflict and explicit merge or reload UX.

4. Billion gap rows across all tenants — first change per layer (DB / API / UI)

Layer First change
Database Partition or shard by tenant_id (and/or time) so cold tenants don’t bloat hot paths; covering indexes for (tenant_id, …filters/sort…, id) so list + count stay index-friendly at scale.
API Cursor-based list endpoints (no deep OFFSET), strict page caps, and optional summary rollups / async materialised counts so /summary doesn’t scan billions per request.
UI Incremental loading only (never assume full result sets), tighter cache eviction for infinite-query pages, and row-level optimistic patch targets so the client doesn’t pretend it holds “the whole world” in memory.