Files
qbit-document-generation/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPostgres.java
T
james.maesandClaude Fable 5.2 cfbf0744f9
maven / build (push) Successful in 6m14s
build / maven (push) Successful in 6m14s
fix(postgres): accept full_page_writes off on Amazon Aurora PostgreSQL only
Generation qualification refused every Amazon Aurora PostgreSQL server:
Aurora always reports full_page_writes = off and has no parameter to change
it, because its storage layer does not write torn pages.

The settings query now also returns
to_regprocedure('aurora_version()') IS NOT NULL (true on Aurora, false on
stock PostgreSQL, never an error). full_page_writes = off is accepted only
when that is true. Stock PostgreSQL must still report it on, and fsync on,
synchronous_commit on or remote_apply and every other check are unchanged
on both. The refusal text now says full-page writes are not required on
Aurora.

Tests run on a real postgres:17 engine whose settings are really changed;
only Aurora is simulated, by creating a function named aurora_version() in
the test database.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-09 20:25:55 -06:00

230 lines
11 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
/*******************************************************************************
** Qualification of the native QQQ PostgreSQL17 backend (vendor and backend type
** "postgres") and this QBit's owned tables. Observes the live connection only;
** it never changes session or server settings, schema or data. Amazon Aurora
** PostgreSQL is the one accepted variant: see durable().
*******************************************************************************/
final class GenerationPostgres
{
static final String VENDOR = "postgres";
private static final String SETTINGS = "SELECT current_setting('server_version_num')::int, current_setting('transaction_read_only'), pg_is_in_recovery(),"
+ " current_setting('synchronous_commit'), current_setting('fsync'), current_setting('full_page_writes'), current_setting('TimeZone'),"
+ " (SELECT setting::bigint FROM pg_settings WHERE name='lock_timeout'), current_setting('transaction_isolation'), current_schema(), current_database(),"
+ " to_regprocedure('aurora_version()') IS NOT NULL";
private static final String REQUIREMENTS = "Generation requires synchronous PostgreSQL commits, full-page writes (except on Amazon Aurora PostgreSQL),"
+ " a primary writable server, READ COMMITTED and UTC sessions";
private static final String TABLE = "SELECT c.relpersistence FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace"
+ " WHERE n.nspname=current_schema() AND c.relname=? AND c.relkind='r'";
private static final String COLLATIONS = "SELECT COUNT(*) FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace"
+ " JOIN pg_collation co ON co.oid=a.attcollation WHERE n.nspname=current_schema() AND c.relname=? AND a.attnum>0 AND NOT a.attisdropped AND NOT co.collisdeterministic";
private static final String UNIQUES = "SELECT i.relname, a.attname, ix.indisprimary FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid"
+ " JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace n ON n.oid=t.relnamespace"
+ " CROSS JOIN LATERAL unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord)"
+ " JOIN pg_attribute a ON a.attrelid=t.oid AND a.attnum=k.attnum"
+ " WHERE n.nspname=current_schema() AND t.relname=? AND ix.indisunique AND ix.indisvalid AND ix.indpred IS NULL AND ix.indexprs IS NULL AND k.ord<=ix.indnkeyatts"
+ " ORDER BY i.relname, k.ord";
/***************************************************************************
** Static policy, not a configurable SQL or connection provider.
***************************************************************************/
private GenerationPostgres() {}
/***************************************************************************
** The native postgres module identity, not merely a vendor string.
***************************************************************************/
static boolean is(RDBMSBackendMetaData backend)
{
return (VENDOR.equals(backend.getVendor()) && VENDOR.equals(backend.getBackendType()));
}
/***************************************************************************
** Backend metadata must name one concrete, credential-free PostgreSQL target.
***************************************************************************/
static boolean configured(RDBMSBackendMetaData backend)
{
try
{
String url = ConnectionManager.getJdbcUrl(backend);
return (is(backend) && url != null && url.startsWith("jdbc:postgresql://") && backend.getDatabaseName() != null && !backend.getDatabaseName().isBlank());
}
catch(RuntimeException unsupported)
{
return (false);
}
}
/***************************************************************************
** Commit durability of the observed server. Every server needs
** synchronous_commit on or remote_apply and fsync on. Stock PostgreSQL also
** needs full_page_writes on, which protects crash recovery from torn pages.
** Amazon Aurora PostgreSQL runs with full_page_writes off and offers no
** parameter to change it, because its storage layer does not write torn
** pages; so off is accepted there, and only there. Aurora is detected in the
** settings query by to_regprocedure('aurora_version()') IS NOT NULL, which is
** true on Aurora, false on stock PostgreSQL and never raises an error (calling
** a missing function would abort the transaction).
***************************************************************************/
static boolean durable(String synchronousCommit, String fsync, String fullPageWrites, boolean aurora)
{
return (("on".equals(synchronousCommit) || "remote_apply".equals(synchronousCommit)) && "on".equals(fsync)
&& ("on".equals(fullPageWrites) || aurora && "off".equals(fullPageWrites)));
}
/***************************************************************************
** Exact server, durability, session and owned-table checks for PostgreSQL 17,
** with the single Amazon Aurora PostgreSQL allowance stated on durable().
***************************************************************************/
static void qualify(Connection connection, RDBMSBackendMetaData backend) throws SQLException, QException
{
if(!"PostgreSQL".equals(connection.getMetaData().getDatabaseProductName()) || connection.getMetaData().getDatabaseMajorVersion() != 17
|| !Objects.equals(backend.getDatabaseName(), connection.getCatalog())
|| !ConnectionManager.getJdbcUrl(backend).split("\\?")[0].equals(connection.getMetaData().getURL().split("\\?")[0]))
{
throw new QException("Generation requires the configured native PostgreSQL17 database.");
}
try(var query = GenerationJdbc.prepare(connection, SETTINGS); var row = query.executeQuery())
{
if(!row.next())
{
throw new QException(REQUIREMENTS + ".");
}
List<String> weak = new java.util.ArrayList<>();
if(row.getInt(1) / 10000 != 17 || row.getString(10) == null || !Objects.equals(backend.getDatabaseName(), row.getString(11)))
{
weak.add("database");
}
if(!"off".equals(row.getString(2)) || row.getBoolean(3))
{
weak.add("writable primary");
}
if(!durable(row.getString(4), row.getString(5), row.getString(6), row.getBoolean(12)))
{
weak.add("synchronous commit");
}
if(!Set.of("UTC", "Etc/UTC").contains(row.getString(7)))
{
weak.add("UTC session");
}
if(!"read committed".equals(row.getString(9)))
{
weak.add("READ COMMITTED");
}
if(!weak.isEmpty())
{
throw new QException(REQUIREMENTS + "; not satisfied: " + String.join(", ", weak));
}
long lockTimeout = row.getLong(8);
if(lockTimeout < 1 || lockTimeout > 5000)
{
throw new QException("Generation requires a bounded PostgreSQL lock_timeout of one to five seconds.");
}
}
qualifyTables(connection, backend);
}
/***************************************************************************
** Permanent tables only (unlogged tables are truncated by crash recovery),
** deterministic comparison and complete native idempotency constraints.
***************************************************************************/
private static void qualifyTables(Connection connection, RDBMSBackendMetaData backend) throws SQLException, QException
{
GenerationJdbc.validateMappings(backend);
var config = DocumentGenerationQBitConfig.from(QContext.getQInstance());
for(var table : GenerationTables.tables(config))
{
if(!Objects.equals(backend.getName(), QContext.getQInstance().getTable(table.getName()).getBackendName()))
{
throw new QException("Generation owned tables require one qualified PostgreSQL backend.");
}
try(var query = GenerationJdbc.prepare(connection, TABLE))
{
query.setString(1, table.getName());
try(var row = query.executeQuery())
{
if(!row.next() || !"p".equals(row.getString(1)) || row.next())
{
throw new QException("Generation tables require permanent (logged) PostgreSQL tables.");
}
}
}
try(var query = GenerationJdbc.prepare(connection, COLLATIONS))
{
query.setString(1, table.getName());
try(var row = query.executeQuery())
{
if(!row.next() || row.getInt(1) != 0)
{
throw new QException("Generation columns require exact deterministic comparison.");
}
}
}
Map<String, String> indexes = new HashMap<>();
try(var query = GenerationJdbc.prepare(connection, UNIQUES))
{
query.setString(1, table.getName());
try(var row = query.executeQuery())
{
while(row.next())
{
indexes.merge(row.getBoolean(3) ? "PRIMARY" : row.getString(1), row.getString(2), (left, right) -> left + "," + right);
}
}
}
if(!"id".equals(indexes.get("PRIMARY")))
{
throw new QException("Generation requires native immutable primary keys.");
}
for(UniqueKey key : table.getUniqueKeys() == null ? List.<UniqueKey>of() : table.getUniqueKeys())
{
String expected = String.join(",", key.getFieldNames().stream().map(name -> table.getField(name).getBackendName()).toList());
if(!indexes.containsValue(expected))
{
throw new QException("Generation requires complete database idempotency constraints.");
}
}
}
}
}