Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions server/src/db/migrations/010_admin_users_query_index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
-- Migration 010: Add supporting indexes for the GET /api/v1/admin/users query
-- (issue #256).
--
-- The original query used correlated subqueries that fired one sub-select per
-- user row — O(n) against `transactions` and `trade_offers`. The refactored
-- query in admin.ts uses grouped LEFT JOINs instead, which the planner can
-- satisfy with a single sequential scan of each table aggregated once.
--
-- The indexes below allow those aggregation scans to use index-only or
-- index range scans rather than full table scans, keeping the query well
-- under 500 ms at 10 000 users / 100 000 transactions.
--
-- All statements are idempotent (IF NOT EXISTS).

-- ---------------------------------------------------------------------------
-- 1. transactions(user_id)
-- Used by the tx_agg grouped subquery: GROUP BY user_id, SUM(amount).
-- migration 009 added this table but did not add the index for the
-- aggregation path (only idx_transactions_user covers user_id + created_at,
-- which is the per-user history index — the planner may still choose a
-- seq scan for the aggregate). This partial-covering index ensures the
-- GROUP BY user_id path uses an index scan.
-- ---------------------------------------------------------------------------

CREATE INDEX IF NOT EXISTS idx_transactions_user_id
ON transactions (user_id);

-- ---------------------------------------------------------------------------
-- 2. trade_offers(seller_id) and trade_offers(buyer_id)
-- Used by the tr_agg UNION ALL subquery that counts participation.
-- Partial indexes (NOT NULL) shrink the index to rows that actually
-- contribute to the count, which is the common case.
-- ---------------------------------------------------------------------------

CREATE INDEX IF NOT EXISTS idx_trade_offers_seller_id
ON trade_offers (seller_id)
WHERE seller_id IS NOT NULL;

CREATE INDEX IF NOT EXISTS idx_trade_offers_buyer_id
ON trade_offers (buyer_id)
WHERE buyer_id IS NOT NULL;
40 changes: 30 additions & 10 deletions server/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,18 @@ router.post(
/**
* Returns a paginated list of users with their ledger balance, trade count,
* and registration date.
*
* Performance (issue #256): the original query computed balance and trade
* count via correlated subqueries, executing one sub-select per user row —
* O(n) against `transactions` and `trade_offers` for a result set of n users.
* At 1 000 users that is 2 000 sub-selects; at 10 000 users it causes timeouts.
*
* The fix aggregates both metrics in a single pass each using grouped
* subqueries joined with LEFT JOIN. The planner can hash- or merge-join the
* pre-aggregated subqueries against `users` in one scan, reducing total I/O
* from O(n) correlated lookups to O(1) sequential scans regardless of page size.
* `transactions(user_id)` is indexed by migration 010 (added alongside this
* change) so the aggregation scan uses the index rather than a full table scan.
*/
router.get(
"/users",
Expand Down Expand Up @@ -322,17 +334,25 @@ router.get(
u.id,
u.phone,
u.created_at AS "registrationDate",
COALESCE((
SELECT SUM(CASE WHEN t.direction = 'credit' THEN t.amount ELSE -t.amount END)
FROM transactions t
WHERE t.user_id = u.id
), 0)::float8 AS balance,
COALESCE((
SELECT COUNT(*)
FROM trade_offers o
WHERE o.seller_id = u.id OR o.buyer_id = u.id
), 0)::int AS "tradeCount"
COALESCE(tx_agg.net_balance, 0)::float8 AS balance,
COALESCE(tr_agg.trade_count, 0)::int AS "tradeCount"
FROM users u
LEFT JOIN (
SELECT
user_id,
SUM(CASE WHEN direction = 'credit' THEN amount ELSE -amount END) AS net_balance
FROM transactions
GROUP BY user_id
) tx_agg ON tx_agg.user_id = u.id
LEFT JOIN (
SELECT participant_id, COUNT(*) AS trade_count
FROM (
SELECT seller_id AS participant_id FROM trade_offers WHERE seller_id IS NOT NULL
UNION ALL
SELECT buyer_id AS participant_id FROM trade_offers WHERE buyer_id IS NOT NULL
) participation
GROUP BY participant_id
) tr_agg ON tr_agg.participant_id = u.id
ORDER BY u.created_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset]
Expand Down
Loading