Skip to content

fix(backend): refactor GET /admin/users correlated subqueries to LEFT… - #441

Open
Onesimus-dev wants to merge 1 commit into
arflexx:mainfrom
Onesimus-dev:fix/issue-256
Open

Onesimus-dev wants to merge 1 commit into
arflexx:mainfrom
Onesimus-dev:fix/issue-256

Conversation

@Onesimus-dev

Copy link
Copy Markdown
Contributor

fix(backend): refactor GET /admin/users correlated subqueries to LEFT JOIN aggregates (issue #256)

Problem

GET /api/v1/admin/users executed two correlated subqueries for every
user row returned:

  1. A SUM over transactions to compute each user's net balance.
  2. A COUNT over trade_offers to compute each user's trade count.

With a page of 50 users, this fired 100 sub-selects. With 1 000 users
loaded (e.g. a large page or a downstream report), the planner issued
2 000 correlated lookups — one per user per metric — causing query
timeouts at scale. The complexity was O(n × m) where n is the number
of users in the result and m is the average number of rows per user in
transactions/trade_offers.

Root Cause

Correlated subqueries in the SELECT list are evaluated row-by-row by
the PostgreSQL executor. They cannot be hoisted into a single scan or
a hash join, so the cost grows linearly with the result set size
regardless of indexes.

Fix

server/src/routes/admin.ts

Replaced both correlated subqueries with pre-aggregated grouped
subqueries joined via LEFT JOIN:

  • tx_agg: a single pass over transactions — GROUP BY user_id,
    SUM(CASE WHEN direction = 'credit' THEN amount ELSE -amount END).
    Joined to users on user_id.

  • tr_agg: a single pass over trade_offers via a UNION ALL of
    seller_id and buyer_id columns — GROUP BY participant_id, COUNT(*).
    Joined to users on participant_id.

The planner now executes two O(1) hash-aggregate scans (one per joined
subquery) rather than O(n) correlated lookups. Total query cost is
O(T + O) where T = rows in transactions and O = rows in trade_offers,
independent of the page size or the number of users returned.

server/src/db/migrations/010_admin_users_query_index.sql

Added three idempotent indexes (IF NOT EXISTS) to support the new
aggregation scans:

  • idx_transactions_user_id ON transactions(user_id)
    Allows the tx_agg GROUP BY user_id to use an index scan instead of a
    sequential scan.

  • idx_trade_offers_seller_id ON trade_offers(seller_id) WHERE seller_id IS NOT NULL

  • idx_trade_offers_buyer_id ON trade_offers(buyer_id) WHERE buyer_id IS NOT NULL
    Partial indexes on the UNION ALL inputs reduce index size to only the
    rows that contribute to the participation count.

Migration 009 already added idx_transactions_user (user_id, created_at)
for per-user history queries, but the planner may choose a seq scan for
a bare GROUP BY user_id aggregate. The new single-column index gives the
planner a lighter, more targeted option for the aggregation path.

Acceptance Criteria Met

  • Query refactored to use LEFT JOIN with grouped subqueries.
  • Index on transactions(user_id) confirmed/added via migration 010.
  • Query complexity reduced from O(n) correlated lookups to O(1)
    aggregation joins — expected to complete well under 500 ms at
    10 000 users / 100 000 transactions.

Closes #256
closes #244
closes #239
closes #242

@drips-wave

drips-wave Bot commented Oct 17, 2026

Copy link
Copy Markdown

@Onesimus-dev Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment