mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-12 13:28:55 +00:00
perf(properties): LATERAL lookups + recommendation by property_id
/api/properties timed out (15s) on just 12 properties: joining the 26M-row recommendation table on the unindexed plan_id forced a full-table hash, and the multi-million-row plan/funding joins compounded it. Rewrite getProperties/getPropertiesCount to read the denormalised model (no plan_recommendations / recommendation_materials): one-row LATERAL lookups for target/plan/funding and a recommendation aggregate keyed by the INDEXED recommendation.property_id, dropping the GROUP BY entirely. ~1s -> instant for a 12-property portfolio. Also fix the reporting page for new-approach properties (current_sap_points / current_epc_rating on the property row are unwritten): source avg SAP and the EPC band distribution from property_baseline_performance (lodged_sap_score / lodged_epc_band) via new sapSql / epcBandSql helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f3e00429f9
commit
14f06abb6d
3 changed files with 61 additions and 51 deletions
|
|
@ -9,6 +9,8 @@ import {
|
|||
energyConsumptionSql,
|
||||
estimatedSql,
|
||||
isExpiredSql,
|
||||
sapSql,
|
||||
epcBandSql,
|
||||
} from "@/lib/services/epcSources";
|
||||
|
||||
import type {
|
||||
|
|
@ -36,7 +38,7 @@ export async function getAverages(
|
|||
): Promise<AverageMetrics> {
|
||||
const result = await db.execute<AverageMetrics>(sql`
|
||||
SELECT
|
||||
AVG(p.current_sap_points)::float AS avg_sap,
|
||||
AVG(${sapSql})::float AS avg_sap,
|
||||
AVG(${carbonSql(sql`e`)})::float AS avg_carbon,
|
||||
AVG(${billsSql(sql`e`)})::float AS avg_bills,
|
||||
AVG(${energyConsumptionSql(sql`e`)})::float AS avg_energy_consumption
|
||||
|
|
@ -97,7 +99,7 @@ export async function getCountByEpcBand(
|
|||
SELECT *
|
||||
FROM (
|
||||
SELECT
|
||||
COALESCE(p.current_epc_rating::text, 'Unknown') AS epc,
|
||||
COALESCE((${epcBandSql})::text, 'Unknown') AS epc,
|
||||
COUNT(*) FILTER (
|
||||
WHERE ${estimatedSql(sql`e`)} = false
|
||||
)::int AS actual,
|
||||
|
|
|
|||
|
|
@ -682,7 +682,14 @@ export async function getPropertiesCount(
|
|||
FROM property p
|
||||
LEFT JOIN property_details_epc epc ON epc.property_id = p.id
|
||||
${newApproachJoins}
|
||||
LEFT JOIN plan pl ON pl.property_id = p.id AND pl.is_default = true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id, post_sap_points FROM plan
|
||||
WHERE property_id = p.id
|
||||
AND portfolio_id = p.portfolio_id
|
||||
AND is_default = true
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) pl ON true
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
${combinedWhere}
|
||||
`);
|
||||
|
|
@ -714,8 +721,8 @@ export async function getProperties(
|
|||
t.epc AS "targetEpc",
|
||||
pl.id AS "planId",
|
||||
fp.scheme AS "fundingScheme",
|
||||
COALESCE(SUM(r.sap_points), 0) AS "totalRecommendationSapPoints",
|
||||
COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost",
|
||||
COALESCE(rec.sap_points, 0) AS "totalRecommendationSapPoints",
|
||||
COALESCE(rec.cost, 0) AS "totalRecommendationCost",
|
||||
p.landlord_property_id AS "landlordPropertyId",
|
||||
p.original_sap_points AS "originalSapPoints",
|
||||
p.property_type AS "propertyType",
|
||||
|
|
@ -729,57 +736,47 @@ export async function getProperties(
|
|||
${mainfuelSql(sql`epc`)} AS mainfuel,
|
||||
p.lexiscore AS lexiscore
|
||||
FROM property p
|
||||
LEFT JOIN property_targets t
|
||||
ON t.property_id = p.id
|
||||
LEFT JOIN plan pl
|
||||
ON pl.property_id = p.id
|
||||
AND pl.is_default = true
|
||||
LEFT JOIN funding_package fp
|
||||
ON fp.plan_id = pl.id
|
||||
-- Recommendations link to their plan via recommendation.plan_id (the
|
||||
-- plan_recommendations join table is retired). Using plan_id is what makes
|
||||
-- Plan Cost / recommendation SAP populate for new-approach properties.
|
||||
LEFT JOIN recommendation r
|
||||
ON r.plan_id = pl.id
|
||||
AND r.default = true
|
||||
AND r.already_installed = false
|
||||
-- LATERAL one-row lookups keep this query at "12 properties × index probe"
|
||||
-- instead of hash-joining the multi-million-row plan/recommendation tables.
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT epc FROM property_targets
|
||||
WHERE property_id = p.id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) t ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT * FROM plan
|
||||
WHERE property_id = p.id
|
||||
AND portfolio_id = p.portfolio_id
|
||||
AND is_default = true
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) pl ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT scheme FROM funding_package
|
||||
WHERE plan_id = pl.id
|
||||
LIMIT 1
|
||||
) fp ON true
|
||||
-- Active (default, not-yet-installed) recommendations for the property,
|
||||
-- read denormalised. We aggregate by recommendation.property_id — the only
|
||||
-- indexed access path (there is NO index on recommendation.plan_id, and the
|
||||
-- table has tens of millions of rows) — and rely on the partial index on
|
||||
-- (default, already_installed). The retired plan_recommendations join table
|
||||
-- is intentionally not used.
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COALESCE(SUM(sap_points), 0) AS sap_points,
|
||||
COALESCE(SUM(estimated_cost), 0) AS cost
|
||||
FROM recommendation
|
||||
WHERE property_id = p.id
|
||||
AND "default" = true
|
||||
AND already_installed = false
|
||||
) rec ON true
|
||||
LEFT JOIN property_details_epc epc
|
||||
ON epc.property_id = p.id
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
${combinedWhere}
|
||||
GROUP BY
|
||||
p.id,
|
||||
p.portfolio_id,
|
||||
p.address,
|
||||
p.postcode,
|
||||
p.status,
|
||||
p.creation_status,
|
||||
p.current_epc_rating,
|
||||
p.current_sap_points,
|
||||
t.epc,
|
||||
pl.id,
|
||||
fp.scheme,
|
||||
p.landlord_property_id,
|
||||
p.original_sap_points,
|
||||
p.property_type,
|
||||
p.built_form,
|
||||
p.tenure,
|
||||
p.year_built,
|
||||
-- legacy + new-source columns referenced by the branched EPC expressions
|
||||
p.updated_at,
|
||||
epc.lodgement_date,
|
||||
epc.is_expired,
|
||||
epc.total_floor_area,
|
||||
epc.co2_emissions,
|
||||
epc.mainfuel,
|
||||
epl.id,
|
||||
epp.id,
|
||||
epl.registration_date,
|
||||
epl.inspection_date,
|
||||
epl.total_floor_area_m2,
|
||||
bp.lodged_co2_emissions_t_per_yr,
|
||||
p.lexiscore
|
||||
LIMIT ${limit} OFFSET ${offset};
|
||||
`);
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,17 @@ export const carbonSql = (e: Alias) =>
|
|||
export const energyConsumptionSql = (e: Alias) =>
|
||||
sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_primary_energy_intensity_kwh_per_m2_yr ELSE ${e}.primary_energy_consumption END`;
|
||||
|
||||
/**
|
||||
* Baseline SAP score. New: the lodged (gov-registry) score from
|
||||
* property_baseline_performance (the property-row current_sap_points isn't
|
||||
* written for new-approach properties). Legacy: property.current_sap_points.
|
||||
* Swap to `effective_sap_score` for the modelling-adjusted baseline.
|
||||
*/
|
||||
export const sapSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_sap_score ELSE p.current_sap_points END`;
|
||||
|
||||
/** Baseline EPC band. New: lodged band from property_baseline_performance. Legacy: property.current_epc_rating. */
|
||||
export const epcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_epc_band ELSE p.current_epc_rating END`;
|
||||
|
||||
/**
|
||||
* Annual energy bill (£). New: sum of the individual cost columns on
|
||||
* property_baseline_performance (NULL when no baseline row exists, so it's
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue