perf(geoportal): materialize simplified UAT geometries (fixes 90% CPU on PostgreSQL)
ST_SimplifyPreserveTopology in views runs on every Martin tile request, causing constant CPU load. Fix: pre-compute simplified geometries into dedicated columns (geom_z0, geom_z5, geom_z8) on the GisUat table. POST /api/geoportal/optimize-views: 1. Adds geom_z0/z5/z8 columns to GisUat 2. Backfills with pre-computed simplifications (one-time cost) 3. Creates GiST spatial indexes on each 4. Replaces views to use pre-computed columns (zero CPU reads) 5. Updates trigger to auto-compute on INSERT/UPDATE Setup banner: now checks optimization status and shows "Optimizeaza" button if needed. One-click, then docker restart martin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/geoportal/optimize-views
|
||||||
|
*
|
||||||
|
* Replaces on-the-fly ST_Simplify views with materialized columns.
|
||||||
|
* This eliminates CPU-heavy geometry simplification on every Martin tile request.
|
||||||
|
*
|
||||||
|
* What it does:
|
||||||
|
* 1. Adds geom_z0/z5/z8 columns to GisUat table (pre-simplified geometry)
|
||||||
|
* 2. Backfills them from the original geom column
|
||||||
|
* 3. Creates spatial indexes on each
|
||||||
|
* 4. Replaces views to use pre-computed columns instead of on-the-fly simplification
|
||||||
|
*
|
||||||
|
* Safe to re-run (idempotent). Original geom column is NEVER modified.
|
||||||
|
*/
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/core/storage/prisma";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
// 1. Add pre-simplified geometry columns
|
||||||
|
{
|
||||||
|
name: "Add geom_z0 column (2000m)",
|
||||||
|
sql: `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='GisUat' AND column_name='geom_z0') THEN ALTER TABLE "GisUat" ADD COLUMN geom_z0 geometry(Geometry, 3844); END IF; END $$`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Add geom_z5 column (500m)",
|
||||||
|
sql: `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='GisUat' AND column_name='geom_z5') THEN ALTER TABLE "GisUat" ADD COLUMN geom_z5 geometry(Geometry, 3844); END IF; END $$`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Add geom_z8 column (50m)",
|
||||||
|
sql: `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='GisUat' AND column_name='geom_z8') THEN ALTER TABLE "GisUat" ADD COLUMN geom_z8 geometry(Geometry, 3844); END IF; END $$`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. Backfill with pre-computed simplified geometries
|
||||||
|
{
|
||||||
|
name: "Backfill geom_z0 (2000m simplification)",
|
||||||
|
sql: `UPDATE "GisUat" SET geom_z0 = ST_SimplifyPreserveTopology(geom, 2000) WHERE geom IS NOT NULL AND geom_z0 IS NULL`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Backfill geom_z5 (500m simplification)",
|
||||||
|
sql: `UPDATE "GisUat" SET geom_z5 = ST_SimplifyPreserveTopology(geom, 500) WHERE geom IS NOT NULL AND geom_z5 IS NULL`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Backfill geom_z8 (50m simplification)",
|
||||||
|
sql: `UPDATE "GisUat" SET geom_z8 = ST_SimplifyPreserveTopology(geom, 50) WHERE geom IS NOT NULL AND geom_z8 IS NULL`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. Spatial indexes
|
||||||
|
{
|
||||||
|
name: "Index geom_z0",
|
||||||
|
sql: `CREATE INDEX IF NOT EXISTS gis_uat_geom_z0_idx ON "GisUat" USING GIST (geom_z0)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Index geom_z5",
|
||||||
|
sql: `CREATE INDEX IF NOT EXISTS gis_uat_geom_z5_idx ON "GisUat" USING GIST (geom_z5)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Index geom_z8",
|
||||||
|
sql: `CREATE INDEX IF NOT EXISTS gis_uat_geom_z8_idx ON "GisUat" USING GIST (geom_z8)`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4. Replace views to use pre-computed columns (zero CPU on read)
|
||||||
|
{
|
||||||
|
name: "Replace gis_uats_z0 view",
|
||||||
|
sql: `CREATE OR REPLACE VIEW gis_uats_z0 AS SELECT siruta, name, geom_z0 AS geom FROM "GisUat" WHERE geom_z0 IS NOT NULL`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Replace gis_uats_z5 view",
|
||||||
|
sql: `CREATE OR REPLACE VIEW gis_uats_z5 AS SELECT siruta, name, geom_z5 AS geom FROM "GisUat" WHERE geom_z5 IS NOT NULL`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Replace gis_uats_z8 view",
|
||||||
|
sql: `CREATE OR REPLACE VIEW gis_uats_z8 AS SELECT siruta, name, county, geom_z8 AS geom FROM "GisUat" WHERE geom_z8 IS NOT NULL`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Replace gis_uats_z12 view (original geom)",
|
||||||
|
sql: `CREATE OR REPLACE VIEW gis_uats_z12 AS SELECT siruta, name, county, geom FROM "GisUat" WHERE geom IS NOT NULL`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 5. Update trigger to also compute simplified geoms on INSERT/UPDATE
|
||||||
|
{
|
||||||
|
name: "Update trigger to pre-compute simplified geoms",
|
||||||
|
sql: `CREATE OR REPLACE FUNCTION gis_uat_sync_geom() RETURNS TRIGGER AS $$ BEGIN IF NEW.geometry IS NOT NULL THEN BEGIN NEW.geom := gis_uat_esri_to_geom(NEW.geometry::jsonb); IF NEW.geom IS NOT NULL THEN NEW.geom_z0 := ST_SimplifyPreserveTopology(NEW.geom, 2000); NEW.geom_z5 := ST_SimplifyPreserveTopology(NEW.geom, 500); NEW.geom_z8 := ST_SimplifyPreserveTopology(NEW.geom, 50); END IF; EXCEPTION WHEN OTHERS THEN NEW.geom := NULL; NEW.geom_z0 := NULL; NEW.geom_z5 := NULL; NEW.geom_z8 := NULL; END; ELSE NEW.geom := NULL; NEW.geom_z0 := NULL; NEW.geom_z5 := NULL; NEW.geom_z8 := NULL; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const results: string[] = [];
|
||||||
|
try {
|
||||||
|
for (const step of STEPS) {
|
||||||
|
await prisma.$executeRawUnsafe(step.sql);
|
||||||
|
results.push(`${step.name} OK`);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ status: "ok", results });
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : "Eroare";
|
||||||
|
return NextResponse.json({ status: "error", results, error: msg }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET — check optimization status */
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const cols = await prisma.$queryRaw`
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'GisUat' AND column_name LIKE 'geom_z%'
|
||||||
|
` as Array<{ column_name: string }>;
|
||||||
|
|
||||||
|
const optimized = cols.length >= 3;
|
||||||
|
return NextResponse.json({ optimized, columns: cols.map((c) => c.column_name) });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ optimized: false, columns: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1,65 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Database, Loader2, CheckCircle2, AlertTriangle } from "lucide-react";
|
import { Database, Loader2, CheckCircle2, AlertTriangle, Zap } from "lucide-react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
type Status = "checking" | "needed" | "running" | "done" | "error" | "ready";
|
type Status = "checking" | "views-needed" | "optimize-needed" | "running" | "done" | "error" | "ready";
|
||||||
|
|
||||||
export function SetupBanner() {
|
export function SetupBanner() {
|
||||||
const [status, setStatus] = useState<Status>("checking");
|
const [status, setStatus] = useState<Status>("checking");
|
||||||
const [missing, setMissing] = useState<string[]>([]);
|
const [message, setMessage] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
// Check on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/geoportal/setup-views")
|
(async () => {
|
||||||
.then((r) => r.json())
|
try {
|
||||||
.then((d: { ready: boolean; missing: string[] }) => {
|
// Check views
|
||||||
if (d.ready) setStatus("ready");
|
const vr = await fetch("/api/geoportal/setup-views").then((r) => r.json());
|
||||||
else { setStatus("needed"); setMissing(d.missing); }
|
if (!vr.ready) {
|
||||||
})
|
setStatus("views-needed");
|
||||||
.catch(() => setStatus("ready")); // don't block if check fails
|
setMessage(`${vr.missing?.length ?? 0} view-uri PostGIS lipsesc`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Check optimization
|
||||||
|
const or2 = await fetch("/api/geoportal/optimize-views").then((r) => r.json());
|
||||||
|
if (!or2.optimized) {
|
||||||
|
setStatus("optimize-needed");
|
||||||
|
setMessage("View-urile UAT calculeaza geometria on-the-fly (CPU intensiv). Optimizeaza pentru performanta.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("ready");
|
||||||
|
} catch {
|
||||||
|
setStatus("ready");
|
||||||
|
}
|
||||||
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const runSetup = async () => {
|
const runAction = async (endpoint: string) => {
|
||||||
setStatus("running");
|
setStatus("running");
|
||||||
try {
|
try {
|
||||||
const r = await fetch("/api/geoportal/setup-views", { method: "POST" });
|
const r = await fetch(endpoint, { method: "POST" });
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.status === "ok") setStatus("done");
|
if (d.status === "ok") {
|
||||||
else { setStatus("error"); setError(d.error ?? "Eroare necunoscuta"); }
|
setStatus("done");
|
||||||
|
setMessage("Restart Martin pe server: docker restart martin");
|
||||||
|
} else {
|
||||||
|
setStatus("error");
|
||||||
|
setError(d.error ?? "Eroare");
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setError(e instanceof Error ? e.message : "Eroare retea");
|
setError(e instanceof Error ? e.message : "Eroare retea");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't show if ready or still checking
|
|
||||||
if (status === "ready" || status === "checking") return null;
|
if (status === "ready" || status === "checking") return null;
|
||||||
|
|
||||||
// Auto-hide after success
|
|
||||||
if (status === "done") {
|
if (status === "done") {
|
||||||
return (
|
return (
|
||||||
<div className="bg-green-500/10 border border-green-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs text-green-700 dark:text-green-400">
|
<div className="bg-green-500/10 border border-green-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs text-green-700 dark:text-green-400">
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
View-uri create cu succes. Restarteaza Martin pe server: <code className="bg-muted px-1 rounded">docker restart martin</code>
|
<span>{message}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,28 +67,27 @@ export function SetupBanner() {
|
|||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
return (
|
return (
|
||||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs text-red-700 dark:text-red-400">
|
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs text-red-700 dark:text-red-400">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
Eroare: {error}
|
{error}
|
||||||
<Button variant="outline" size="sm" className="h-6 text-xs ml-auto" onClick={runSetup}>Reincearca</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isViews = status === "views-needed";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs">
|
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs">
|
||||||
<Database className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0" />
|
{isViews
|
||||||
<span className="text-amber-700 dark:text-amber-300">
|
? <Database className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0" />
|
||||||
{missing.length} view-uri PostGIS lipsesc ({missing.join(", ")}). Necesare pentru tile-uri UAT optimizate.
|
: <Zap className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0" />}
|
||||||
</span>
|
<span className="text-amber-700 dark:text-amber-300 flex-1">{message}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline" size="sm" className="h-6 text-xs shrink-0"
|
||||||
size="sm"
|
|
||||||
className="h-6 text-xs ml-auto shrink-0"
|
|
||||||
onClick={runSetup}
|
|
||||||
disabled={status === "running"}
|
disabled={status === "running"}
|
||||||
|
onClick={() => runAction(isViews ? "/api/geoportal/setup-views" : "/api/geoportal/optimize-views")}
|
||||||
>
|
>
|
||||||
{status === "running" ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
|
{status === "running" && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||||
{status === "running" ? "Se creaza..." : "Creaza view-uri"}
|
{isViews ? "Creaza view-uri" : "Optimizeaza"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user