From 72e87b2b9456c5fc84d44cf41125898f9c73da81 Mon Sep 17 00:00:00 2001 From: Arnold Castro Date: Thu, 2 Jul 2026 23:37:56 -0400 Subject: [PATCH 1/2] fix: add statement_timeout and propagate errors from buildMapInstance - Set statement_timeout (default 30s, override with STATEMENT_TIMEOUT env) on every pooled connection, so a hung query is killed by our own client instead of waiting for the database server's 600 second limit. Each failed query then holds a shared pgpool slot for at most 30 seconds. - Move the async work out of the Promise executor in buildMapInstance. A rejection inside an async executor never rejects the outer promise, so database errors left the HTTP request hanging forever with no response. Errors now reach the route handler and return HTTP 500. This completes the error path started in #111: database error in, clean HTTP error out. All four tile routes go through this function. For Greenstand/treetracker-infrastructure#315 (Fix 1 plus the error propagation gap found in the July 2 investigation). --- greenstand/app.js | 55 +++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/greenstand/app.js b/greenstand/app.js index c9e3790f0..61ebf5e99 100644 --- a/greenstand/app.js +++ b/greenstand/app.js @@ -9,12 +9,27 @@ const {Config} = require("./config"); const connectionString = process.env.DB_URL; const max = process.env.PG_POOL_SIZE && parseInt(process.env.PG_POOL_SIZE) || 10; -log.info("pool settings:db:%s; pool size: %d", connectionString, max); -const pool = new Pool({ +const statementTimeout = process.env.STATEMENT_TIMEOUT && parseInt(process.env.STATEMENT_TIMEOUT) || 30000; +log.info("pool settings:db:%s; pool size: %d; statement_timeout: %d", connectionString, max, statementTimeout); +const pool = new Pool({ connectionString, max, }); +//kill any query that runs longer than statementTimeout on our side, +//instead of waiting for the database server's 600s limit +pool.on('connect', (client) => { + client.query(`SET statement_timeout = ${statementTimeout}`).catch(e => { + log.error("failed to set statement_timeout:", e); + }); +}); + +//an idle client that errors (e.g. its connection is dropped) makes the pool +//emit 'error'; without a listener that event crashes the whole process +pool.on('error', (e) => { + log.error("idle db client error:", e); +}); + const config = new Config(pool); @@ -43,25 +58,27 @@ app.use('/viewer/images', express.static(images)); async function buildMapInstance(x, y, z, params){ - const map = await new Promise(async (res, rej) => { + //do the async work OUTSIDE the Promise executor: a throw/rejection inside + //an async executor does not reject the outer promise, it leaves it pending + //forever and the http request hangs with no response + const bboxDb = mercator.xyz_to_envelope_db_buffer(//x, y, z, false); + parseInt(x), + parseInt(y), + parseInt(z), + false, + 100, + ); + const bounds = bboxDb.join(","); + log.debug("bounds:", bounds); + const xmlString = await config.getXMLString({ + zoomLevel: z, + bounds, + ...params, + }); + + const map = await new Promise((res, rej) => { const mapInstance = new mapnik.Map(256, 256); mapInstance.registerFonts(path.join(__dirname, '../test/data/map-a/'), {recurse:true}); - - const bboxDb = mercator.xyz_to_envelope_db_buffer(//x, y, z, false); - parseInt(x), - parseInt(y), - parseInt(z), - false, - 100, - ); - const bounds = bboxDb.join(","); - log.debug("bounds:", bounds); - const xmlString = await config.getXMLString({ - zoomLevel: z, - bounds, - ...params, - }); - mapInstance.fromString(xmlString, { strict: true, base: __dirname, From 390591475ff7e0e66a8a9684a4c52d351dcf8ada Mon Sep 17 00:00:00 2001 From: Arnold Castro Date: Tue, 7 Jul 2026 22:33:57 -0400 Subject: [PATCH 2/2] Lower default statement_timeout to 25s so it fires before the 30s CloudFront edge timeout The production tile hosts (tiles and tiles1-4.treetracker.org) sit behind a CloudFront distribution whose origin response timeout is 30 seconds, with up to 3 attempts. With the default equal to 30s the two deadlines race: if CloudFront gives up first it retries the same multi-hour query. A 25s default guarantees the database returns its error before the edge deadline, so CloudFront delivers the error instead of retrying. Still overridable via STATEMENT_TIMEOUT. --- greenstand/app.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/greenstand/app.js b/greenstand/app.js index 61ebf5e99..1d9b41eb9 100644 --- a/greenstand/app.js +++ b/greenstand/app.js @@ -9,7 +9,8 @@ const {Config} = require("./config"); const connectionString = process.env.DB_URL; const max = process.env.PG_POOL_SIZE && parseInt(process.env.PG_POOL_SIZE) || 10; -const statementTimeout = process.env.STATEMENT_TIMEOUT && parseInt(process.env.STATEMENT_TIMEOUT) || 30000; +//default must stay below the CloudFront edge timeout (30s) so the DB gives up first and the edge does not retry the same query +const statementTimeout = process.env.STATEMENT_TIMEOUT && parseInt(process.env.STATEMENT_TIMEOUT) || 25000; log.info("pool settings:db:%s; pool size: %d; statement_timeout: %d", connectionString, max, statementTimeout); const pool = new Pool({ connectionString,