9.3 KiB
Database performance audit
Scope and safety
This audit covers the PostgreSQL schema, indexes, constraints, views, functions, and their relationship to src/matrix/nimble/conf/nimble.qry, with particular focus on Visit MIS and Dashboard.
The catalog inspection is read-only. No indexes, views, functions, or PostgreSQL settings have been changed. The audited local database is only about 30 MB and its core business tables currently contain almost no live data, so local execution times cannot represent production. Final index selection must be validated with production statistics and EXPLAIN (ANALYZE, BUFFERS) captured for representative parameters.
Inventory
- PostgreSQL: 18.4
- User tables: 120
- Normal views: 80
- Materialized views: 2
- User functions: 220
- Indexes: 187
- Function statistics: disabled (
track_functions = none) - Statement statistics: unavailable (
pg_stat_statementsis not preloaded)
The raw catalog exports are under target/database-audit/ and can be regenerated with matrix.nimble.DatabaseAuditRunner.
The subsequent analysis of the complete matrix.sql schema dump confirmed that portfolio_bank_branch and colony_allocation are normal views rather than tables. Index recommendations therefore target their underlying tables (portfolio_branch, bank_branch, portfolio, colony, belt_allocation, and verifier).
Generated index script: docs/production-indexes.sql. It contains preflight
checks, high-confidence Visit MIS/Dashboard indexes, foreign-key support indexes,
statistics refresh, postflight validation, and a commented rollback reference.
Application-to-database mapping
| Feature | Application query | Database entry point |
|---|---|---|
| Visit MIS | Query306 | monthvisitmis(...) |
| Dashboard initial load | Query473 | dashboard() |
| Dashboard partial refresh | Query525 | dashboardpartial(...) |
nimble.qry contains about 460 application query definitions. Its most frequently referenced core relation is main, followed by document/bank-report relations, app_user, main_mainopr, verifier, temp_reporting, and portfolio_bank_branch. This is source frequency, not runtime frequency; production statement statistics are required for workload ranking.
Visit MIS findings
Execution shape
monthvisitmis(...) loops over each requested day and performs an aggregate against visit_mis. For every verifier/operator row returned, it runs a second query to find max(doneon). This is an N+1 query pattern: cost grows with both date range and verifier count.
vervisitmis(...) has the same per-verifier max(doneon) lookup and an additional per-verifier aggregate against verifier_monthly_data. It also dynamically switches between current_visit_mis and visit_mis.
Both functions build complete HTML reports inside PL/pgSQL through repeated text concatenation. This increases database CPU and memory usage and prevents the Java/UI layer from receiving structured rows. It is not the first optimization target, but should ultimately be replaced with a set-returning/data function and JSP rendering.
View expansion
visit_mis and current_visit_mis each expand into three UNION ALL branches for residence, office, and property visits. Every branch joins:
main_mainopr, itself a join ofmainandmain_operationsbyuuidportfolio_bank_branchbyportbranch_idverifierby the visit-specific verifier columnapp_userby verifier operatorriskalertsby(uuid, visit)
Every branch also performs a correlated zone aggregation against colony_allocation by verifier_id.
verifier_monthly_data is another three-branch UNION ALL over main, filtered by receivedate, visit flag, verifier, and same-address flag.
Index gaps and mismatches
These are candidates to validate on production, not DDL to run blindly:
riskalertshas separate indexes onuuidandvisit, although the views join using both. A composite index beginning with(uuid, visit)matches the access path better.colony_allocationis a view. Its base tablebelt_allocationhas an index onverifier_id, but lacks an index beginning withbelt_idfor its join tocolony; the generated script adds(belt_id, verifier_id).main_operationshas single-column indexes onoprsendon,sdoneon,sdone, andfileclosed, but the report predicates combine time/status data after joining byuuid. Production plans are needed to decide between composite and partial indexes.mainhas individual verifier indexes and areceivedateindex.verifier_monthly_datacombines each verifier withreceivedate, a visit flag, and same-address status, so one-column indexes may cause excessive filtering after index access.portfolio_bank_branchis a view. Its base tableportfolio_branchalready has a primary key onportbranch_id; additional indexes are recommended for its portfolio/active and bank-branch access paths.- Several existing indexes duplicate or overlap:
main.bank_branch_idhas two btree indexes, andmain_operations.uuidhas both a primary btree index and a hash index. Production usage and index size/write cost should be checked before removing anything.
Primary query rewrite
The per-verifier max(doneon) must be folded into the main grouped query as max(doneon) FILTER (...). Monthly visit count/limit should also be pre-aggregated once and joined by verifier. This changes roughly 1 + N + N queries per report/day into one grouped query per day, or preferably one query for the entire requested range.
The date loop can be replaced by a single generate_series relation joined to the source data, or by grouping source rows by reporting date. The best variant depends on the required backlog semantics and will be verified against current output before replacement.
Dashboard findings
dashboard() executes at least ten child functions serially:
dashvisits()dashscannerhourly()dashoperatorhourly('opr')dashrihhourly()dashoperatorhourly('edp')dashhourlytobesolved()dashriskandphoto()dashageing()dash3monthsfigures(1, 0)dash3monthsfigures(0, 0)
The major repeated work is:
dashvisits()repeatedly counts the same expandedcurrent_visit_misandscanning_gridviews for different categories.dashageing()scansscanning_gridabout seven times to produce age buckets.dashboardpartial()callsdash3monthsfigures()twice for one widget refresh.- Dashboard views expand through
main,main_operations,temp_reporting,riskalerts, user/company/branch/portfolio lookups, and report tables.
These should be consolidated using conditional aggregates (count(*) FILTER (WHERE ...)) so each source is scanned once per widget. The initial dashboard response should then call fewer functions or one data-returning function. UI polling should avoid starting a refresh while the previous request is still running.
Schema and observability findings
- The database relies heavily on views and PL/pgSQL functions, including dynamic SQL. PostgreSQL dependency metadata cannot discover relations embedded only in dynamic SQL, so function definitions must be reviewed in addition to catalog dependencies.
- A number of foreign keys lack a leading index. These matter mainly for parent updates/deletes and child-side joins; each must be ranked by actual workload rather than indexed automatically.
- Core tables contain some redundant indexes, while some logical join keys have no visible supporting index.
track_functions = noneprevents attribution of time to the 220 functions.pg_stat_statementsis unavailable, preventing reliable ranking by total time, mean time, calls, rows, and temporary I/O.
Required production evidence
Before applying point 5, collect during a normal busy period:
- Row counts and relation/index sizes for the Visit MIS and Dashboard base tables.
pg_stat_user_tablesandpg_stat_user_indexessnapshots before and after the period.EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE)for Visit MIS source aggregates using representative branch, verifier, and date-range values.- Slow statement data from
pg_stat_statementsafter enabling it through the normal PostgreSQL change process. - Function timing after setting
track_functions = pl(orall) through the normal production change process. - PostgreSQL logs with
log_min_duration_statementset to a suitable temporary threshold, if extensions/settings cannot be enabled immediately.
Do not run EXPLAIN ANALYZE on production-changing functions. Extract and explain their underlying SELECT statements inside a read-only transaction.
Recommended implementation order for point 5
- Enable observability and capture a baseline.
- Add only production-plan-confirmed indexes, using
CREATE INDEX CONCURRENTLYand checking duplicate/overlapping indexes first. - Rewrite Visit MIS to eliminate N+1 lookups and repeated date scans while preserving report totals exactly.
- Consolidate Dashboard counts into single-pass aggregates.
- Return structured data from new versioned functions and render HTML in the application; retain old functions during comparison.
- Run old and new implementations side by side for representative dates/branches and compare every total before switching.
- Load-test with at least 20 concurrent sessions and monitor database CPU, pool wait time, query latency, temporary I/O, and heap usage.